Skip to content

fix(config): the request body limit is the adapter's 512KB default, set in zero files, and below maxPayloadBytes #59

Description

@xe-nvdk

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>
  1. 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.)
  2. Early-reject on a Content-Length already over the limit → 413, before awaiting anything.
  3. 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().
  4. Decode with new TextDecoder('utf-8', { fatal: true }) so truncated or invalid UTF-8 is a clean 400 rather than U+FFFD smuggling.
  5. 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

Activity

  1. xe-nvdk commented on Sep 14, 2026

    @xe-nvdk
    MemberAuthor

    Correction to this issue's premise

    Found while reviewing #72. The request side is not unbounded — I overstated it when filing.

    @sveltejs/adapter-node enforces BODY_SIZE_LIMIT in get_raw_body, before request.arrayBuffer() is ever reached, and it has a default:

    node_modules/@sveltejs/adapter-node/files/handler.js:1137
    const body_size_limit = parseInt(env('BODY_SIZE_LIMIT', '524288')) || undefined;
    

    So the effective default ceiling is 512 KB, not unlimited, and it applies to chunked requests too (handler.js:1002 picks the constraint name based on whether a content-length was present).

    The issue is still real but narrower, and the title should change: it is "the body limit is the adapter's 512 KB default and is set in zero files", not "bodies are unbounded". Two things that remain worth doing:

    1. Set it deliberately. 512 KB is an accident of the adapter's default rather than a decision. LIMITS.maxPayloadBytes is 1 MiB for a dashboard document, so the current default is below what the dashboard API's own validator permits — a 600 KB dashboard would be rejected by the adapter with a generic error before validation could produce a useful one. That mismatch is the actual bug.
    2. Decide whether the proxy needs a different limit from the app. An Arc write forwarded through the proxy is a different size class from a dashboard save, and both currently share one number that nobody chose.

    Sorry for the noise on the original framing — the adapter guard is easy to miss because it lives in generated output rather than our source.

  2. changed the title [-]fix(security): request bodies are unbounded under chunked transfer encoding[/-] [+]fix(config): the request body limit is the adapter's 512KB default, set in zero files, and below maxPayloadBytes[/+] on Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions