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

@@ -155,9 +155,10 @@ describe('calculateInterestScore', () => {
// High engagement: 5 notes, 3 emails, 2 reminders
const selectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn()
.mockResolvedValueOnce([{ value: 5 }]) // notes
.mockResolvedValueOnce([{ value: 2 }]) // reminders
where: vi
.fn()
.mockResolvedValueOnce([{ value: 5 }]) // notes
.mockResolvedValueOnce([{ value: 2 }]) // reminders
.mockResolvedValueOnce([{ value: 3 }]), // emails
};
(db.select as ReturnType<typeof vi.fn>).mockReturnValue(selectChain);
@@ -254,12 +255,20 @@ describe('calculateInterestScore', () => {
const selectChain = makeSelectChain(0);
(db.select as ReturnType<typeof vi.fn>).mockReturnValue(selectChain);
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({ ...base, id: 'i6', berthId: 'b1' });
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
...base,
id: 'i6',
berthId: 'b1',
});
const withBerth = await calculateInterestScore('i6', 'p1');
expect(withBerth.breakdown.berthLinked).toBe(25);
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({ ...base, id: 'i7', berthId: null });
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
...base,
id: 'i7',
berthId: null,
});
const withoutBerth = await calculateInterestScore('i7', 'p1');
expect(withoutBerth.breakdown.berthLinked).toBe(0);
});
@@ -269,7 +278,11 @@ describe('calculateInterestScore', () => {
await expect(calculateInterestScore('missing', 'p1')).rejects.toThrow('Interest not found');
});
it('returns cached result when redis has a hit', async () => {
it('returns cached result when redis has a hit (after port-scope DB check)', async () => {
// Security fix: the DB lookup runs FIRST to confirm the interest is
// in the caller's port. Only then is the (port-scoped) cache key read.
// A test that asserts the DB is bypassed would be asserting the
// pre-fix bug; this test asserts the new ordering.
const cachedScore = {
totalScore: 42,
breakdown: {
@@ -281,11 +294,26 @@ describe('calculateInterestScore', () => {
},
calculatedAt: new Date().toISOString(),
};
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 'cached-id',
portId: 'p1',
clientId: 'c1',
createdAt: daysAgo(10),
pipelineStage: 'open',
eoiStatus: null,
contractStatus: null,
depositStatus: null,
dateEoiSigned: null,
dateContractSigned: null,
dateDepositReceived: null,
berthId: null,
});
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify(cachedScore));
const result = await calculateInterestScore('cached-id', 'p1');
expect(result.totalScore).toBe(42);
// Should NOT hit the database
expect(db.query.interests.findFirst).not.toHaveBeenCalled();
// Port-scope check: the DB IS hit, but no other queries (notes/threads)
// are needed since the cache served the score body.
expect(db.query.interests.findFirst).toHaveBeenCalled();
});
});