Context
Found while adversarially reviewing #21's plan. This affects every API route in the repo, not just the dashboard ones, so it is filed separately rather than fixed inside a feature PR.
There is no bound on request body size
BODY_SIZE_LIMIT appears in zero files — not the Dockerfile, not helm/, not .env.example, not docker-compose.yml. So adapter-node's documented 512 KB default is the only ambient bound.
Except it is not a bound. From node_modules/@sveltejs/adapter-node/files/handler.js:
const content_length = Number(h['content-length']); // NaN when chunked
if (body_size_limit !== undefined && content_length > body_size_limit) { // NaN > 524288 === false
// 413
}
...
size += chunk.length;
if (size > content_length) { // size > NaN === false
const constraint = content_length ? 'content-length' : 'BODY_SIZE_LIMIT';
...
}
Under Transfer-Encoding: chunked with no Content-Length, both guards are no-ops. That constraint ternary is the adapter's author documenting a BODY_SIZE_LIMIT fallback that was never written.
Attack
Any authenticated user POSTs to any JSON route with Transfer-Encoding: chunked and streams an arbitrarily large body. adapter-node buffers every byte; the route's await request.json() then concatenates it into one string.
A handful of concurrent requests OOM-kills the process. Launchpad is single-process (helm/launchpad/values.yaml: replicaCount: 1) and better-sqlite3 is synchronous, so this takes down the control plane for every tenant, not just the attacker's org.
Why the obvious fix is insufficient
Setting BODY_SIZE_LIMIT is worth doing but does not close it — the chunked path never consults it. Reading request.text() and checking the length afterwards does not close it either: by the time that resolves, the allocation has already happened.
Scope
Add a shared helper in src/lib/server/util.ts and route every JSON write through it:
readBoundedJsonBody(request: Request, maxBytes: number): Promise<unknown>
- Require
Content-Type: application/json → 415 otherwise. (Also permanent CSRF hardening: SvelteKit's origin check only blocks cross-origin form content types, so a JSON-only route cannot be reached by an HTML form even if csrf.checkOrigin is ever relaxed.)
- Early-reject on a
Content-Length already over the limit → 413, before awaiting anything.
- Read
request.body with a reader, summing chunk.byteLength (already UTF-8 bytes), and abort the stream the instant the running total exceeds the limit → 413. Never await request.text().
- Decode with
new TextDecoder('utf-8', { fatal: true }) so truncated or invalid UTF-8 is a clean 400 rather than U+FFFD smuggling.
- Treat an empty body as 400 —
handler.js returns a null body when there is no content-type at all, which surfaces as ''.
Also set BODY_SIZE_LIMIT explicitly in the Helm deployment and Dockerfile as belt-and-braces, with a comment noting it does not bind for chunked bodies.
Note the interaction with #20: LIMITS.maxPayloadBytes is 1 MiB while adapter-node's default is 512 KB, so a valid maximum-size dashboard is currently rejected by the adapter before any handler runs. Whatever BODY_SIZE_LIMIT is set to must be at or above LIMITS.maxPayloadBytes.
Acceptance criteria
- A chunked body over the limit is rejected with 413 and bounded peak RSS — the memory assertion is the one that proves the fix, not the status code
- A body with a lying
Content-Length is bounded by the actual byte count
- Invalid UTF-8 yields 400, not a replacement character reaching the validator
- A non-JSON
Content-Type yields 415
BODY_SIZE_LIMIT is set in the deployment and is >= LIMITS.maxPayloadBytes
- Existing routes using
request.json() are migrated, or an issue is filed listing which remain
Context
Found while adversarially reviewing #21's plan. This affects every API route in the repo, not just the dashboard ones, so it is filed separately rather than fixed inside a feature PR.
There is no bound on request body size
BODY_SIZE_LIMITappears in zero files — not the Dockerfile, nothelm/, not.env.example, notdocker-compose.yml. So adapter-node's documented 512 KB default is the only ambient bound.Except it is not a bound. From
node_modules/@sveltejs/adapter-node/files/handler.js:Under
Transfer-Encoding: chunkedwith noContent-Length, both guards are no-ops. Thatconstraintternary is the adapter's author documenting aBODY_SIZE_LIMITfallback that was never written.Attack
Any authenticated user POSTs to any JSON route with
Transfer-Encoding: chunkedand streams an arbitrarily large body. adapter-node buffers every byte; the route'sawait request.json()then concatenates it into one string.A handful of concurrent requests OOM-kills the process. Launchpad is single-process (
helm/launchpad/values.yaml:replicaCount: 1) andbetter-sqlite3is synchronous, so this takes down the control plane for every tenant, not just the attacker's org.Why the obvious fix is insufficient
Setting
BODY_SIZE_LIMITis worth doing but does not close it — the chunked path never consults it. Readingrequest.text()and checking the length afterwards does not close it either: by the time that resolves, the allocation has already happened.Scope
Add a shared helper in
src/lib/server/util.tsand route every JSON write through it:Content-Type: application/json→ 415 otherwise. (Also permanent CSRF hardening: SvelteKit's origin check only blocks cross-origin form content types, so a JSON-only route cannot be reached by an HTML form even ifcsrf.checkOriginis ever relaxed.)Content-Lengthalready over the limit → 413, before awaiting anything.request.bodywith a reader, summingchunk.byteLength(already UTF-8 bytes), and abort the stream the instant the running total exceeds the limit → 413. Neverawait request.text().new TextDecoder('utf-8', { fatal: true })so truncated or invalid UTF-8 is a clean 400 rather than U+FFFD smuggling.handler.jsreturns a null body when there is nocontent-typeat all, which surfaces as''.Also set
BODY_SIZE_LIMITexplicitly in the Helm deployment and Dockerfile as belt-and-braces, with a comment noting it does not bind for chunked bodies.Note the interaction with #20:
LIMITS.maxPayloadBytesis 1 MiB while adapter-node's default is 512 KB, so a valid maximum-size dashboard is currently rejected by the adapter before any handler runs. WhateverBODY_SIZE_LIMITis set to must be at or aboveLIMITS.maxPayloadBytes.Acceptance criteria
Content-Lengthis bounded by the actual byte countContent-Typeyields 415BODY_SIZE_LIMITis set in the deployment and is >=LIMITS.maxPayloadBytesrequest.json()are migrated, or an issue is filed listing which remain