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:
@@ -4,11 +4,25 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { getPort, updatePort } from '@/lib/services/ports.service';
|
||||
import { updatePortSchema } from '@/lib/validators/ports';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Non-super-admin callers (e.g. port directors holding admin.manage_settings)
|
||||
* may only read/mutate THEIR OWN port row. The path id is therefore
|
||||
* compared against ctx.portId and a foreign target is rejected before the
|
||||
* service is touched. Super-admins retain unrestricted access.
|
||||
*/
|
||||
function assertPortInScope(targetPortId: string, ctx: { portId: string; isSuperAdmin: boolean }) {
|
||||
if (ctx.isSuperAdmin) return;
|
||||
if (targetPortId !== ctx.portId) {
|
||||
throw new ForbiddenError('Cross-tenant port access denied');
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, _ctx, params) => {
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
|
||||
try {
|
||||
assertPortInScope(params.id!, ctx);
|
||||
const data = await getPort(params.id!);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
@@ -20,6 +34,7 @@ export const GET = withAuth(
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
||||
try {
|
||||
assertPortInScope(params.id!, ctx);
|
||||
const body = await parseBody(req, updatePortSchema);
|
||||
const data = await updatePort(params.id!, body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -4,11 +4,18 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { listPorts, createPort } from '@/lib/services/ports.service';
|
||||
import { createPortSchema } from '@/lib/validators/ports';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
|
||||
// Listing every tenant and creating new tenants are super-admin operations:
|
||||
// a port director must not be able to enumerate other ports (target
|
||||
// discovery for cross-tenant attacks) or spin up new tenants whose admin
|
||||
// they implicitly become.
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async () => {
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Listing all ports requires super-admin');
|
||||
}
|
||||
const data = await listPorts();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
@@ -20,6 +27,9 @@ export const GET = withAuth(
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Creating ports requires super-admin');
|
||||
}
|
||||
const body = await parseBody(req, createPortSchema);
|
||||
const data = await createPort(body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -4,14 +4,17 @@ import { withAuth } from '@/lib/api/helpers';
|
||||
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(async (_req, _ctx, params) => {
|
||||
export const GET = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
const { jobId } = params;
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await getEmailDraftResult(jobId);
|
||||
const result = await getEmailDraftResult(jobId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
});
|
||||
|
||||
if (result === null) {
|
||||
return NextResponse.json({ status: 'processing' });
|
||||
|
||||
Reference in New Issue
Block a user