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:
@@ -10,6 +10,9 @@ import {
|
||||
} from '@/lib/db/schema/documents';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { companies } from '@/lib/db/schema/companies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
@@ -258,9 +261,90 @@ export async function getDocumentById(id: string, portId: string) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any subject FK (clientId / interestId / companyId / yachtId /
|
||||
* reservationId) that points at a row outside the caller's port. Without
|
||||
* this guard, a port-A user could create a document whose subject is a
|
||||
* port-B client and then exfiltrate the foreign client's name + email
|
||||
* via sendForSigning's Documenso payload, or via the local watcher /
|
||||
* notification surfaces that hydrate the linked entity.
|
||||
*/
|
||||
async function assertSubjectFksInPort(
|
||||
portId: string,
|
||||
fks: {
|
||||
clientId?: string | null;
|
||||
interestId?: string | null;
|
||||
companyId?: string | null;
|
||||
yachtId?: string | null;
|
||||
reservationId?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const checks: Array<Promise<void>> = [];
|
||||
if (fks.clientId) {
|
||||
checks.push(
|
||||
db.query.clients
|
||||
.findFirst({ where: and(eq(clients.id, fks.clientId), eq(clients.portId, portId)) })
|
||||
.then((row) => {
|
||||
if (!row) throw new ValidationError('clientId not found in this port');
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (fks.interestId) {
|
||||
checks.push(
|
||||
db.query.interests
|
||||
.findFirst({
|
||||
where: and(eq(interests.id, fks.interestId), eq(interests.portId, portId)),
|
||||
})
|
||||
.then((row) => {
|
||||
if (!row) throw new ValidationError('interestId not found in this port');
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (fks.companyId) {
|
||||
checks.push(
|
||||
db.query.companies
|
||||
.findFirst({
|
||||
where: and(eq(companies.id, fks.companyId), eq(companies.portId, portId)),
|
||||
})
|
||||
.then((row) => {
|
||||
if (!row) throw new ValidationError('companyId not found in this port');
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (fks.yachtId) {
|
||||
checks.push(
|
||||
db.query.yachts
|
||||
.findFirst({ where: and(eq(yachts.id, fks.yachtId), eq(yachts.portId, portId)) })
|
||||
.then((row) => {
|
||||
if (!row) throw new ValidationError('yachtId not found in this port');
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (fks.reservationId) {
|
||||
checks.push(
|
||||
db.query.berthReservations
|
||||
.findFirst({
|
||||
where: and(
|
||||
eq(berthReservations.id, fks.reservationId),
|
||||
eq(berthReservations.portId, portId),
|
||||
),
|
||||
})
|
||||
.then((row) => {
|
||||
if (!row) throw new ValidationError('reservationId not found in this port');
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(checks);
|
||||
}
|
||||
|
||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
|
||||
await assertSubjectFksInPort(portId, {
|
||||
clientId: data.clientId,
|
||||
interestId: data.interestId,
|
||||
});
|
||||
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
@@ -364,14 +448,20 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
||||
if (!doc.fileId) throw new ValidationError('Document has no associated file');
|
||||
if (doc.status !== 'draft') throw new ConflictError('Document is not in draft status');
|
||||
|
||||
// Fetch interest + client to build signers
|
||||
// Fetch interest + client to build signers. Filter by portId in addition
|
||||
// to the FK so that even if a stale or maliciously-set subject FK on the
|
||||
// document points at a foreign-port row, this signing flow refuses to
|
||||
// hydrate (and therefore refuses to ship to Documenso) data from outside
|
||||
// the caller's tenant.
|
||||
const interest = doc.interestId
|
||||
? await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId) })
|
||||
? await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, doc.interestId), eq(interests.portId, portId)),
|
||||
})
|
||||
: null;
|
||||
|
||||
const client = doc.clientId
|
||||
? await db.query.clients.findFirst({
|
||||
where: eq(clients.id, doc.clientId),
|
||||
where: and(eq(clients.id, doc.clientId), eq(clients.portId, portId)),
|
||||
with: { contacts: true },
|
||||
})
|
||||
: null;
|
||||
@@ -1198,6 +1288,14 @@ export async function createFromWizard(
|
||||
throw new ValidationError('templateId is required for template source');
|
||||
}
|
||||
|
||||
await assertSubjectFksInPort(portId, {
|
||||
clientId: data.clientId,
|
||||
interestId: data.interestId,
|
||||
companyId: data.companyId,
|
||||
yachtId: data.yachtId,
|
||||
reservationId: data.reservationId,
|
||||
});
|
||||
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
@@ -1275,6 +1373,14 @@ export async function createFromUpload(
|
||||
throw new NotFoundError('File');
|
||||
}
|
||||
|
||||
await assertSubjectFksInPort(portId, {
|
||||
clientId: data.clientId,
|
||||
interestId: data.interestId,
|
||||
companyId: data.companyId,
|
||||
yachtId: data.yachtId,
|
||||
reservationId: data.reservationId,
|
||||
});
|
||||
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
|
||||
Reference in New Issue
Block a user