sec: lock down 5 cross-tenant IDORs uncovered in second-pass review

1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin
   (manage_settings) mutate any other tenant's port row by passing the
   foreign id in the path. Now non-super-admins must target their own
   ctx.portId; listPorts and createPort are super-admin only.

2. HIGH — Invoice create/update accepted arbitrary expenseIds and
   linked them into invoice_expenses with no port check; the GET
   response then re-emitted those foreign expense rows via the
   linkedExpenses join. assertExpensesInPort now validates each id
   belongs to the caller's portId before insert; getInvoiceById's
   join filters by expenses.portId as defense-in-depth.

3. HIGH — Document creation paths (createDocument, createFromWizard,
   createFromUpload) persisted user-supplied clientId/interestId/
   companyId/yachtId/reservationId without verifying those FKs were
   in-port. sendForSigning then loaded the foreign client/interest by
   id alone and pushed their PII into the Documenso payload. New
   assertSubjectFksInPort helper rejects out-of-port FKs at create
   time; sendForSigning's interest+client lookups now also filter by
   portId.

4. MEDIUM — calculateInterestScore read its redis cache before
   verifying portId, and the cache key was interestId-only — a
   foreign-port caller could observe a cached score breakdown.
   Cache key now includes portId, and the port-scope DB lookup runs
   before any cache.get.

5. MEDIUM — AI email-draft job results were retrievable by anyone who
   could guess the BullMQ jobId (default sequential integers). Job
   ids are now random UUIDs, requestEmailDraft validates interestId/
   clientId belong to ctx.portId before enqueueing, the worker's
   client lookup is port-scoped, and getEmailDraftResult requires
   the caller to match the original requester's userId+portId before
   returning the drafted subject/body.

The interest-scoring unit test that asserted "DB is bypassed on cache
hit" is updated to reflect the new (security-correct) ordering.
Two new regression test files cover the email-draft binding (5 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-29 02:48:43 +02:00
parent 4c5334d471
commit e06fb9545b
10 changed files with 453 additions and 64 deletions

View File

@@ -1,3 +1,10 @@
import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
import { ValidationError, ForbiddenError } from '@/lib/errors';
import { getQueue } from '@/lib/queue';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -20,26 +27,50 @@ export interface DraftResult {
/**
* Request an AI-generated email draft.
* Enqueues a job on the 'ai' queue. Returns jobId for polling.
* Job payload contains ONLY entity IDs (no PII).
*
* Generates an opaque random jobId rather than relying on BullMQ's default
* sequential ids — the jobId is the access token for polling, so it must
* not be enumerable. The job payload also captures the requesting user
* + port so the poll endpoint can refuse cross-tenant / cross-user reads.
*
* The interestId and clientId are validated against portId before enqueue
* so a port-A caller cannot trigger a draft built from port-B data.
*/
export async function requestEmailDraft(
userId: string,
request: DraftRequest,
): Promise<{ jobId: string }> {
const aiQueue = getQueue('ai');
const job = await aiQueue.add('generate-email-draft', {
// No PII — only IDs and context parameters
interestId: request.interestId,
clientId: request.clientId,
portId: request.portId,
context: request.context,
additionalInstructions: request.additionalInstructions,
requestedBy: userId,
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, request.interestId), eq(interests.portId, request.portId)),
});
if (!interest) {
throw new ValidationError('interestId not found in this port');
}
const client = await db.query.clients.findFirst({
where: and(eq(clients.id, request.clientId), eq(clients.portId, request.portId)),
});
if (!client) {
throw new ValidationError('clientId not found in this port');
}
return { jobId: job.id! };
const aiQueue = getQueue('ai');
const jobId = randomUUID();
await aiQueue.add(
'generate-email-draft',
{
// No PII — only IDs and context parameters
interestId: request.interestId,
clientId: request.clientId,
portId: request.portId,
context: request.context,
additionalInstructions: request.additionalInstructions,
requestedBy: userId,
},
{ jobId },
);
return { jobId };
}
// ─── Poll for result ──────────────────────────────────────────────────────────
@@ -47,13 +78,26 @@ export async function requestEmailDraft(
/**
* Get the result of an email draft generation job.
* Returns null if still processing.
*
* Verifies the caller (userId + portId) matches the job's original
* requester before returning the drafted subject/body. A foreign caller
* who happens to know the jobId (or stumbles on it) sees null, not the
* drafted content.
*/
export async function getEmailDraftResult(jobId: string): Promise<DraftResult | null> {
export async function getEmailDraftResult(
jobId: string,
caller: { userId: string; portId: string },
): Promise<DraftResult | null> {
const aiQueue = getQueue('ai');
const job = await aiQueue.getJob(jobId);
if (!job) return null;
const data = job.data as { requestedBy?: string; portId?: string } | undefined | null;
if (!data || data.requestedBy !== caller.userId || data.portId !== caller.portId) {
throw new ForbiddenError('Email draft not accessible');
}
const state = await job.getState();
if (state !== 'completed') return null;