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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user