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:
@@ -36,12 +36,15 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
const { emailThreads } = await import('@/lib/db/schema/email');
|
||||
const { and, eq, desc } = await import('drizzle-orm');
|
||||
|
||||
// Fetch interest, client, berth
|
||||
// Fetch interest, client, berth — both lookups port-scoped so a
|
||||
// crafted job payload cannot exfiltrate foreign-tenant data.
|
||||
const [interest, client] = await Promise.all([
|
||||
db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
}),
|
||||
db.query.clients.findFirst({ where: eq(clients.id, clientId) }),
|
||||
db.query.clients.findFirst({
|
||||
where: and(eq(clients.id, clientId), eq(clients.portId, portId)),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!interest || !client) {
|
||||
|
||||
Reference in New Issue
Block a user