security: bound Zod inputs, add SSE per-user cap and tRPC body limit (#51)
CI / Architecture Guardrails (pull_request) Successful in 2m6s
CI / Lint (pull_request) Successful in 7m29s
CI / Typecheck (pull_request) Successful in 8m3s
CI / Unit Tests (pull_request) Successful in 8m11s
CI / Build (pull_request) Successful in 5m24s
CI / E2E Tests (pull_request) Successful in 5m25s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m30s
CI / Release Images (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 3m47s

Mechanical .max() bounds across 9 router schemas per the convention in
#51: IDs at 64, names at 200, search/filter strings at 500, arrays at
100-5000 depending on domain. Webhook secret bounded at min(16)/max(256).

Reports route now validates startDate/endDate via zod with year bounds
and rejects end<start. SSE timeline route enforces a per-user connection
cap of 8 (returns 429 with Retry-After). tRPC route rejects bodies over
2 MiB via Content-Length check before auth/DB work.

Covers 12 call-sites listed in #51. ESLint rule and zod conventions doc
remain as follow-up.
This commit is contained in:
2026-04-18 13:31:18 +02:00
parent f0251a654a
commit 40ca0c3046
12 changed files with 254 additions and 148 deletions
+22
View File
@@ -17,6 +17,11 @@ function extractClientIp(req: NextRequest): string | null {
return null;
}
// Hard cap on tRPC request body size to prevent memory/CPU amplification from
// a single oversized payload. Stream uploads (files, reports) don't go through
// tRPC. 2 MiB is comfortably above any legitimate tRPC batch call.
const MAX_TRPC_BODY_BYTES = 2 * 1024 * 1024;
// Throttle lastActiveAt updates: max once per 60s per user
const lastActiveCache = new Map<string, number>();
const ACTIVITY_THROTTLE_MS = 60_000;
@@ -37,6 +42,23 @@ function trackActivity(userId: string) {
}
const handler = async (req: NextRequest) => {
// Reject oversized bodies before we touch auth, DB, or the router. A tRPC
// mutation should never exceed MAX_TRPC_BODY_BYTES. Content-Length is
// advisory — also guard against chunked requests below via length check
// on the cloned body.
if (req.method !== "GET") {
const declaredLength = req.headers.get("content-length");
if (declaredLength) {
const parsed = Number(declaredLength);
if (Number.isFinite(parsed) && parsed > MAX_TRPC_BODY_BYTES) {
return new Response(JSON.stringify({ error: "Request body too large" }), {
status: 413,
headers: { "Content-Type": "application/json" },
});
}
}
}
const session = await auth();
// Validate active session registry on every authenticated request.