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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { eq, and } from 'drizzle-orm'; 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 { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
@@ -62,7 +62,8 @@ const PERMISSION_BY_ACTION: Record<
archive: { resource: 'interests', action: 'delete' }, 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>; let body: z.infer<typeof bulkSchema>;
try { try {
body = await parseBody(req, bulkSchema); body = await parseBody(req, bulkSchema);
@@ -127,4 +128,5 @@ export const POST = withAuth(async (req, ctx) => {
}; };
return NextResponse.json({ data: { results, summary } }); 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. * 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 // Minimal shape validation — full ReportPayload is structurally typed
// in TS; here we just check it has the basic envelope. // in TS; here we just check it has the basic envelope.
const payloadSchema = z.object({ const payloadSchema = z
.object({
title: z.string().min(1), title: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
filenameSlug: z.string().min(1), filenameSlug: z.string().min(1),
@@ -34,30 +52,49 @@ const payloadSchema = z.object({
from: z.string().datetime(), from: z.string().datetime(),
to: z.string().datetime(), to: z.string().datetime(),
}), }),
kpis: z.array( kpis: z
.array(
z.object({ z.object({
label: z.string(), label: z.string(),
value: z.union([z.string(), z.number()]), value: z.union([z.string(), z.number()]),
hint: z.string().optional(), hint: z.string().optional(),
}), }),
), )
sections: z.array( .max(MAX_KPIS),
sections: z
.array(
z.object({ z.object({
title: z.string(), title: z.string(),
columns: z.array( columns: z
.array(
z.object({ z.object({
key: z.string(), key: z.string(),
label: z.string(), label: z.string(),
align: z.enum(['left', 'right', 'center']).optional(), 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 /** Optional filename override (without extension) — the client
* passes the slug derived from the custom title. */ * passes the slug derived from the custom title. */
filenameOverride: z.string().optional(), 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( export const POST = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { z } from 'zod'; 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 { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service';
@@ -46,7 +46,8 @@ const PERMISSION_BY_ACTION: Record<
archive: { resource: 'residential_interests', action: 'delete' }, 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>; let body: z.infer<typeof bulkSchema>;
try { try {
await assertResidentialModuleEnabled(ctx.portId); 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 { z } from 'zod';
import { eq, and } from 'drizzle-orm'; 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 { parseBody } from '@/lib/api/route-helpers';
import { runBulk } from '@/lib/api/bulk-helpers'; import { runBulk } from '@/lib/api/bulk-helpers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
@@ -33,7 +33,8 @@ const PERMISSION_BY_ACTION = {
remove_tag: 'edit' as const, 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>; let body: z.infer<typeof bulkSchema>;
try { try {
body = await parseBody(req, bulkSchema); body = await parseBody(req, bulkSchema);
@@ -73,4 +74,5 @@ export const POST = withAuth(async (req, ctx) => {
}); });
return NextResponse.json({ data: { results, summary } }); return NextResponse.json({ data: { results, summary } });
}); }),
);

View File

@@ -112,6 +112,15 @@ export function deepMerge(
export function withAuth<TParams extends RouteParams = Record<string, string>>( export function withAuth<TParams extends RouteParams = Record<string, string>>(
handler: RouteHandler<TParams>, handler: RouteHandler<TParams>,
): (req: NextRequest, routeContext: { params: Promise<TParams> }) => Promise<NextResponse> { ): (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) => { return async (req, routeContext) => {
// Mint or accept a request id BEFORE entering the ALS frame so every // Mint or accept a request id BEFORE entering the ALS frame so every
// log line + the response header reference the same value. Clients // 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; 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) { } catch (error) {
return tag(errorResponse(error)); return tag(errorResponse(error));
} }