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:
@@ -135,6 +135,27 @@ async function resolveBillingEntity(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify every supplied expense ID belongs to the caller's port. Without
|
||||
* this gate, a caller could link foreign-port expenses into their own
|
||||
* draft invoice and read those expenses back via getInvoiceById's
|
||||
* `linkedExpenses` join — a cross-tenant data leak.
|
||||
*/
|
||||
async function assertExpensesInPort(
|
||||
tx: typeof db,
|
||||
portId: string,
|
||||
expenseIds: string[],
|
||||
): Promise<void> {
|
||||
if (expenseIds.length === 0) return;
|
||||
const rows = await tx
|
||||
.select({ id: expenses.id })
|
||||
.from(expenses)
|
||||
.where(and(inArray(expenses.id, expenseIds), eq(expenses.portId, portId)));
|
||||
if (rows.length !== expenseIds.length) {
|
||||
throw new ValidationError('One or more expenses not found in this port');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listInvoices(portId: string, query: ListInvoicesInput) {
|
||||
@@ -195,11 +216,14 @@ export async function getInvoiceById(id: string, portId: string) {
|
||||
.where(eq(invoiceLineItems.invoiceId, id))
|
||||
.orderBy(invoiceLineItems.sortOrder);
|
||||
|
||||
// Defense-in-depth: even if a join row somehow points at a foreign-tenant
|
||||
// expense, the WHERE clause filters by expenses.portId so cross-tenant data
|
||||
// can't leak through this read.
|
||||
const linkedExpenses = await db
|
||||
.select({ expense: expenses })
|
||||
.from(invoiceExpenses)
|
||||
.innerJoin(expenses, eq(expenses.id, invoiceExpenses.expenseId))
|
||||
.where(eq(invoiceExpenses.invoiceId, id));
|
||||
.where(and(eq(invoiceExpenses.invoiceId, id), eq(expenses.portId, portId)));
|
||||
|
||||
return {
|
||||
...invoice,
|
||||
@@ -250,8 +274,11 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
|
||||
const feePct = 0;
|
||||
const total = subtotal - discountAmount + feeAmount;
|
||||
|
||||
// BR-045: Verify expenses aren't already linked to a non-draft invoice
|
||||
// BR-045: Verify expenses aren't already linked to a non-draft invoice.
|
||||
// Tenancy guard precedes BR-045 so a foreign-port expense fails with
|
||||
// ValidationError before any further checks (or any join-side leak).
|
||||
const expenseIds = data.expenseIds ?? [];
|
||||
await assertExpensesInPort(tx, portId, expenseIds);
|
||||
if (expenseIds.length > 0) {
|
||||
const alreadyLinked = await tx
|
||||
.select({ expenseId: invoiceExpenses.expenseId })
|
||||
@@ -418,6 +445,9 @@ export async function updateInvoice(
|
||||
|
||||
// Replace expense links if provided
|
||||
if (data.expenseIds !== undefined) {
|
||||
// Tenancy gate first — reject foreign-port expense IDs before
|
||||
// running BR-045 or doing any writes.
|
||||
await assertExpensesInPort(tx, portId, data.expenseIds);
|
||||
// BR-045
|
||||
if (data.expenseIds.length > 0) {
|
||||
const alreadyLinked = await tx
|
||||
|
||||
Reference in New Issue
Block a user