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

@@ -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));
}