fix(audit): rate-limit/DoS — M13 (bulk limiter on 6 routes), M14 (api limiter default in withAuth, fail-open), M15 (export-pdf payload bounds); L21 verified not-a-bug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 13:07:25 +02:00
parent ebe5fe6ed8
commit 64c73a5d77
8 changed files with 518 additions and 457 deletions

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withRateLimit } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { berths, berthTags } from '@/lib/db/schema/berths';
@@ -71,7 +71,8 @@ const PERMISSION_BY_ACTION: Record<
archive: { resource: 'berths', action: 'edit' },
};
export const POST = withAuth(async (req, ctx) => {
export const POST = withAuth(
withRateLimit('bulk', async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
body = await parseBody(req, bulkSchema);
@@ -145,4 +146,5 @@ export const POST = withAuth(async (req, ctx) => {
results,
},
});
});
}),
);

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withRateLimit } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { runBulk } from '@/lib/api/bulk-helpers';
import { db } from '@/lib/db';
@@ -48,7 +48,8 @@ const PERMISSION_BY_ACTION = {
remove_tag: 'edit' as const,
};
export const POST = withAuth(async (req, ctx) => {
export const POST = withAuth(
withRateLimit('bulk', async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
body = await parseBody(req, bulkSchema);
@@ -218,4 +219,5 @@ export const POST = withAuth(async (req, ctx) => {
}
return NextResponse.json({ data: { results, summary } });
});
}),
);

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withRateLimit } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { runBulk } from '@/lib/api/bulk-helpers';
import { db } from '@/lib/db';
@@ -33,7 +33,8 @@ const PERMISSION_BY_ACTION = {
remove_tag: 'edit' as const,
};
export const POST = withAuth(async (req, ctx) => {
export const POST = withAuth(
withRateLimit('bulk', async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
body = await parseBody(req, bulkSchema);
@@ -73,4 +74,5 @@ export const POST = withAuth(async (req, ctx) => {
});
return NextResponse.json({ data: { results, summary } });
});
}),
);

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withRateLimit } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
@@ -62,7 +62,8 @@ const PERMISSION_BY_ACTION: Record<
archive: { resource: 'interests', action: 'delete' },
};
export const POST = withAuth(async (req, ctx) => {
export const POST = withAuth(
withRateLimit('bulk', async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
body = await parseBody(req, bulkSchema);
@@ -127,4 +128,5 @@ export const POST = withAuth(async (req, ctx) => {
};
return NextResponse.json({ data: { results, summary } });
});
}),
);

View File

@@ -24,9 +24,27 @@ import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report';
* option) so we don't have to keep adding routes per report kind.
*/
// M15: this route renders a fully client-supplied payload synchronously
// via `renderToBuffer` on the request thread, gated only by
// `reports.view_dashboard`. Without hard bounds an authed user can POST
// a huge payload and OOM/stall the Node process. The async worker path
// caps rows at REPORT_ROW_CAP (1000); mirror that here, and additionally
// cap the section count, per-section column count, and the total cell
// budget across all sections so a fan-out of many small sections can't
// dodge the per-section row cap.
const REPORT_ROW_CAP = 1_000;
const MAX_SECTIONS = 50;
const MAX_COLUMNS = 50;
const MAX_KPIS = 100;
/** Upper bound on total rendered table cells (rows × columns, summed
* across every section). Sized so the worst case stays well within the
* per-section caps but bounds the aggregate render cost. */
const MAX_TOTAL_CELLS = 200_000;
// Minimal shape validation — full ReportPayload is structurally typed
// in TS; here we just check it has the basic envelope.
const payloadSchema = z.object({
const payloadSchema = z
.object({
title: z.string().min(1),
description: z.string().optional(),
filenameSlug: z.string().min(1),
@@ -34,30 +52,49 @@ const payloadSchema = z.object({
from: z.string().datetime(),
to: z.string().datetime(),
}),
kpis: z.array(
kpis: z
.array(
z.object({
label: z.string(),
value: z.union([z.string(), z.number()]),
hint: z.string().optional(),
}),
),
sections: z.array(
)
.max(MAX_KPIS),
sections: z
.array(
z.object({
title: z.string(),
columns: z.array(
columns: z
.array(
z.object({
key: z.string(),
label: z.string(),
align: z.enum(['left', 'right', 'center']).optional(),
}),
),
rows: z.array(z.record(z.string(), z.unknown())),
)
.max(MAX_COLUMNS),
rows: z.array(z.record(z.string(), z.unknown())).max(REPORT_ROW_CAP),
}),
),
)
.max(MAX_SECTIONS),
/** Optional filename override (without extension) — the client
* passes the slug derived from the custom title. */
filenameOverride: z.string().optional(),
});
})
// Total-cell budget: the per-section `.max()` caps bound each section,
// but a payload could still fan out MAX_SECTIONS × REPORT_ROW_CAP ×
// MAX_COLUMNS cells. Reject any payload whose summed cell count exceeds
// the aggregate budget before it reaches the synchronous renderer.
.refine(
(p) =>
p.sections.reduce((total, s) => total + s.rows.length * Math.max(1, s.columns.length), 0) <=
MAX_TOTAL_CELLS,
{
message: `Report payload exceeds the maximum of ${MAX_TOTAL_CELLS} total cells`,
path: ['sections'],
},
);
export const POST = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withRateLimit } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service';
@@ -46,7 +46,8 @@ const PERMISSION_BY_ACTION: Record<
archive: { resource: 'residential_interests', action: 'delete' },
};
export const POST = withAuth(async (req, ctx) => {
export const POST = withAuth(
withRateLimit('bulk', async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
await assertResidentialModuleEnabled(ctx.portId);
@@ -104,4 +105,5 @@ export const POST = withAuth(async (req, ctx) => {
},
},
});
});
}),
);

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withRateLimit } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { runBulk } from '@/lib/api/bulk-helpers';
import { db } from '@/lib/db';
@@ -33,7 +33,8 @@ const PERMISSION_BY_ACTION = {
remove_tag: 'edit' as const,
};
export const POST = withAuth(async (req, ctx) => {
export const POST = withAuth(
withRateLimit('bulk', async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
body = await parseBody(req, bulkSchema);
@@ -73,4 +74,5 @@ export const POST = withAuth(async (req, ctx) => {
});
return NextResponse.json({ data: { results, summary } });
});
}),
);

View File

@@ -112,6 +112,15 @@ export function deepMerge(
export function withAuth<TParams extends RouteParams = Record<string, string>>(
handler: RouteHandler<TParams>,
): (req: NextRequest, routeContext: { params: Promise<TParams> }) => Promise<NextResponse> {
// M14: apply the broad per-user `api` limiter (120/min) as a default
// backstop for EVERY authenticated v1 request. Tighter named limiters
// (`ai`, `bulk`, `ocr`, …) still compose ON TOP via `withRateLimit`
// inside the handler chain - they use distinct Redis key prefixes, so
// a request that trips a named limiter is counted in its own bucket
// AND this `api` bucket independently (no double-counting within a
// single bucket). `checkRateLimit` fails OPEN on a Redis outage
// (see rate-limit.ts), so this can never lock the API out.
const rateLimited = withRateLimit('api', handler as RouteHandler) as RouteHandler<TParams>;
return async (req, routeContext) => {
// Mint or accept a request id BEFORE entering the ALS frame so every
// log line + the response header reference the same value. Clients
@@ -269,7 +278,10 @@ export function withAuth<TParams extends RouteParams = Record<string, string>>(
};
const params = await routeContext.params;
return tag(await handler(req, ctx, params));
// Call through the `api`-limited wrapper (M14). On a 429 it
// short-circuits before the inner handler; otherwise it
// delegates straight to the original handler.
return tag(await rateLimited(req, ctx, params));
} catch (error) {
return tag(errorResponse(error));
}