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