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

@@ -12,18 +12,22 @@ import { logger } from '@/lib/logger';
export interface InterestScore {
totalScore: number; // 0-100 (normalised)
breakdown: {
pipelineAge: number; // 0-100
stageSpeed: number; // 0-100
pipelineAge: number; // 0-100
stageSpeed: number; // 0-100
documentCompleteness: number; // 0-100
engagement: number; // 0-100
berthLinked: number; // 0 or 25
engagement: number; // 0-100
berthLinked: number; // 0 or 25
};
calculatedAt: Date;
}
// ─── Redis cache ──────────────────────────────────────────────────────────────
const SCORE_KEY = (interestId: string) => `interest-score:${interestId}`;
// Cache key includes portId so a foreign-port caller hitting the same
// interestId never sees a port-A cached value. (Even if interestId is
// already globally unique, baking portId into the key means a stale or
// hostile caller cannot reuse cached entries across tenants.)
const SCORE_KEY = (portId: string, interestId: string) => `interest-score:${portId}:${interestId}`;
const SCORE_TTL = 3600; // 1 hour
// ─── Scoring helpers ──────────────────────────────────────────────────────────
@@ -56,10 +60,7 @@ function scoreStageSpeed(createdAt: Date, pipelineStage: string): number {
return 0;
}
const daysSinceCreation = Math.max(
1,
(Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24),
);
const daysSinceCreation = Math.max(1, (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
// Average days per stage transition
const avgDaysPerStage = daysSinceCreation / stageIndex;
@@ -108,18 +109,10 @@ export async function calculateInterestScore(
interestId: string,
portId: string,
): Promise<InterestScore> {
// Try cache first
try {
const cached = await redis.get(SCORE_KEY(interestId));
if (cached) {
const parsed = JSON.parse(cached) as InterestScore & { calculatedAt: string };
return { ...parsed, calculatedAt: new Date(parsed.calculatedAt) };
}
} catch (err) {
logger.warn({ err, interestId }, 'Redis cache read failed for interest score');
}
// Fetch interest
// Verify the interest belongs to the caller's port BEFORE returning a
// cached value. The cache key now includes portId, but defense-in-depth:
// a port-B caller passing a port-A interestId still gets NotFound
// instead of a leaked score.
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
@@ -128,6 +121,17 @@ export async function calculateInterestScore(
throw new Error(`Interest not found: ${interestId}`);
}
// Try cache (port-scoped key)
try {
const cached = await redis.get(SCORE_KEY(portId, interestId));
if (cached) {
const parsed = JSON.parse(cached) as InterestScore & { calculatedAt: string };
return { ...parsed, calculatedAt: new Date(parsed.calculatedAt) };
}
} catch (err) {
logger.warn({ err, interestId }, 'Redis cache read failed for interest score');
}
// 1. Pipeline age
const pipelineAge = scorePipelineAge(interest.createdAt);
@@ -145,10 +149,7 @@ export async function calculateInterestScore(
.select({ value: count() })
.from(interestNotes)
.where(
and(
eq(interestNotes.interestId, interestId),
gte(interestNotes.createdAt, thirtyDaysAgo),
),
and(eq(interestNotes.interestId, interestId), gte(interestNotes.createdAt, thirtyDaysAgo)),
),
db
.select({ value: count() })
@@ -203,8 +204,10 @@ export async function calculateInterestScore(
// Write to cache (fire-and-forget)
redis
.setex(SCORE_KEY(interestId), SCORE_TTL, JSON.stringify(result))
.catch((err) => logger.warn({ err, interestId }, 'Redis cache write failed for interest score'));
.setex(SCORE_KEY(portId, interestId), SCORE_TTL, JSON.stringify(result))
.catch((err) =>
logger.warn({ err, interestId }, 'Redis cache write failed for interest score'),
);
return result;
}
@@ -227,8 +230,9 @@ export async function calculateBulkScores(
);
return results
.filter((r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> =>
r.status === 'fulfilled',
.filter(
(r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> =>
r.status === 'fulfilled',
)
.map((r) => r.value);
}