[]previous and next page

Limits and untrusted bodies

Limits are enforced before application code runs, in this order: URL length, method, host, body framing, then the body while it is read.

For agentsUpdated

Limits are enforced before application code runs, in this order: URL length, method, host, body framing, then the body while it is read.

security.limits Default Exceeding it
maxUrlLength 8192 characters 414
maxBodyBytes 1 MiB 413, before the first byte when Content-Length says so, otherwise as soon as the stream passes the limit
requestTimeoutMs 30 000 504; event.signal is aborted so your fetches and queries can stop
streamTimeoutMs 120 000 a streamed page that has not finished by then is cut off with an SSR_TIMEOUT error

Two more bounds belong to server functions and hydration: rpc.maxGetPayloadChars (4096, 414 above it), rpc.maxResponseBytes (8 MiB) and maxPayloadBytes (2 MiB of router data per page; a match that would exceed it is left out of the payload). A method outside GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS gets 405 with Allow.

Framing is checked against request smuggling: a Content-Length that is not one plain number (the Fetch API joins duplicate headers with a comma, so 5, 5 fails), Content-Length together with Transfer-Encoding, and any transfer coding other than a single chunked are 400. The Node server rejects a repeated Content-Length, Transfer-Encoding or Host line on the raw header list as well, and a Host that is not a host name or address.

src/routes/api/hang.tsts
/** Never answers; the request timeout does. */
export const GET = (): Promise<never> => new Promise<never>(() => undefined);

Reading a body#

Read JSON with readJson(request) and validate it (server functions validate for you). It answers 415 unless the content type is application/json or application/*+json, and 400 for invalid JSON. parseJsonSafe(text) also rejects what prototype pollution relies on: a __proto__ key at any depth, a constructor or prototype key that holds an object, and nesting deeper than JSON_LIMITS.maxDepth (64). The check is iterative, so a deeply nested body cannot overflow the stack. formToObject turns FormData or URLSearchParams into a null-prototype object and drops those keys. readTextLimited(request, maxBytes) reads at most maxBytes, for an endpoint that accepts less than the handler's limit, and throws BodyTooLargeError (413).

bodies.tsts
import {
  BodyTooLargeError,
  formToObject,
  isHttpError,
  parseJsonSafe,
  readTextLimited,
} from "@shadow-js/dusk/server";

function attempt(label: string, source: string): void {
  try {
    const value = parseJsonSafe(source) as { a?: number };
    console.log(`${label} ${value.a}`);
  } catch (error) {
    console.log(isHttpError(error) ? `${error.status} ${error.message}` : String(error));
  }
}

attempt("ok", '{"a":1}');
attempt("proto", '{"a":1,"__proto__":{"admin":true}}');
attempt("ctor", '{"constructor":{"prototype":{"admin":true}}}');
attempt("deep", `${"[".repeat(70)}${"]".repeat(70)}`);

try {
  await readTextLimited(new Request("http://localhost/", { method: "POST", body: "0123456789" }), 5);
} catch (error) {
  if (error instanceof BodyTooLargeError) console.log(`${error.status} limit ${error.limit}`);
}

console.log(JSON.stringify(formToObject(new URLSearchParams("a=1&a=2&__proto__=x"))));
Expected output
ok 1
400 Invalid JSON
400 Invalid JSON
400 Invalid JSON
413 limit 5
{

Large bodies#

security.limits.maxBodyBytes is one limit for the whole app. The body is streamed through it, but a route that calls request.text(), request.json() or readJson(request) buffers the whole body, up to that limit, in memory: with maxBodyBytes: 512 * 1024 * 1024, one request can hold 512 MiB. Read the body with a bound of its own:

  • readJsonLimited(request, maxBytes) reads JSON and checks it like readJson (415 for another content type, 400 for invalid JSON, a __proto__ key or nesting deeper than 64), but never reads more than maxBytes. It reads the body as a stream and stops at the bound; a Content-Length above the bound is refused before the first byte.
  • readTextLimited(request, maxBytes) is the same for text.
  • apiHandler({ body, maxBodyBytes }) gives an apiHandler the same bound.
  • apiHandler({ bodyTooLargeMessage }) names the message of the 413 when the schema or the handler reads a body over that bound.
  • serverFn({ maxBodyBytes }) sets the bound of one server function's call (see Limits).
  • A route that really takes a large upload reads request.body as a stream and writes it where it belongs, without collecting it.

Above the bound they throw BodyTooLargeError (413), the same error the handler throws when the app limit is passed, so the answer is the same. A maxBytes that is NaN or negative is a RangeError: an unusable bound never means "unbounded" (pass Infinity if you mean it).

src/routes/api/settings.tsts
import { json, readJsonLimited, type RequestEvent } from "@shadow-js/dusk/server";

// The app allows 100 MiB for uploads elsewhere; this route takes a small JSON document.
export async function POST({ request }: RequestEvent) {
  const settings = await readJsonLimited(request, 16 * 1024);
  return json({ saved: true, settings });
}

In development Dusk also warns when an API route reads its body in full while the app limit is above 10 MiB, once per route, with the route, the reader and the limit. It sees readJson(request) and text(), json(), formData(), arrayBuffer() and blob() called on the request object the route receives. It does not see request.clone(), a request you build from it, or a read of a framework endpoint (server functions, the navigation gate, draft mode), and a read through readJsonLimited, readTextLimited or request.body never warns. The check exists only in development: a production handler does not wrap the request or change it in any way. At 10 MiB or less nothing is checked.