Repository navigation
An unusable body-format header drops the body and calls the function with undefined #3130
Description
Describe the bug
A POST whose body-format tag the runtime cannot use does not have its call refused. extractBody falls off the end of its switch and answers undefined, and dispatch spreads that into the function as an argument. The function runs, commits, and answers 200 — on a substituted argument it was never sent.
// packages/web/server-functions/src/shared.ts:879-904
switch (true) {
case format === BodyFormat.Serialized:
return await deserializeStream(clone, codecOptions);
...
case format === BodyFormat.Uint8Array:
return new Uint8Array(await clone.arrayBuffer());
}
return undefined;// packages/web/server-functions/src/server.ts:1135-1150
if (request.method === "POST" && request.body !== null) {
const decoded = await extractBody(request.clone(), codec);
...
if (bodyFormat === BodyFormat.Serialized || bodyFormat === BodyFormat.Json) {
...
return decoded;
}
parsed.push(decoded); // <- undefined becomes argument 0
}The status is the least of it. X-Server-Function-Format: 0 sent once with an unframed body is a 400 — the codec refuses it, which is the behaviour the whole class should have. Sending the same header twice turns that 400 into a 200: Headers comma-joins duplicates, "0, 0" equals no BodyFormat, the codec is never consulted, and the body is dropped on the floor.
Measured on next (d6a4a52f), a function registered as async (...args) => ({ argc: args.length, args }) called with body "[1]":
a header sent twice reads back as "0, 0"
format 0, sent once 400 |
format 0, sent TWICE 200 | {"argc":1,"args":["UNDEFINED"]}
format 8, sent TWICE 200 | {"argc":1,"args":["UNDEFINED"]}
format 99 (unknown tag) 200 | {"argc":1,"args":["UNDEFINED"]}
format '' (empty tag) 200 | {"argc":1,"args":["UNDEFINED"]}
no tag, body sent as text/plain 200 | {"argc":1,"args":["UNDEFINED"]}
format 8 (control) 200 | {"argc":1,"args":["1"]}
How far this reaches. The CSRF gate holds, so this is not a road in from outside: Sec-Fetch-Site: cross-site and same-site both answer 403, and so does a request with neither the header nor a matching Origin. It takes a same-origin caller — which is the ordinary case, not an exotic one:
fetch(url, { method: "POST", body: "[1]" })sendsContent-Type: text/plain;charset=UTF-8, which is neither a known tag nor a form encoding the runtime sniffs. Any hand-written call, and<form enctype="text/plain">, land here.- an integration or peer sending a tag this build does not know (a version-skew shape the repo already thinks about elsewhere);
- a tag duplicated anywhere in transit — a proxy or middleware appending a header that is already present.
The repo's own suite is the sharpest evidence that the substitution is invisible: 11 tests across server-functions-failure-sanitization.spec.tsx and server-functions-request-bounds.spec.tsx post body: "[]" with no format tag and assert a 200. They pass today because the argument silently becomes undefined and nobody looks. Under the fix below they have to tag their bodies.
One thing that is not a bug and constrains the fix: the fall-through is load-bearing on the response side. BodyFormat.Void ("9") has no case at all — its doc comment says so ("Decoding falls through to undefined") — and decodeResponse's own contract is "Resolves undefined for empty bodies and bodies without a recognized encoding (e.g. a raw user Response)". So a blanket terminal default: that throws inside extractBody would break a documented promise. The seam is the request side, where an undecodable body has exactly one honest meaning.
Steps to reproduce
# The published `next` dist-tag is 2.0.0-rc.4, which predates the /data/
# address, so this needs a build of the branch:
# git checkout next && pnpm install
# pnpm --filter @solidjs/web build:js
node repro.mjsrepro.mjs:
import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const srv = await import("@solidjs/web/server-functions/server");
// reports what it was actually called with
srv.registerServerFunction("sink", async (...args) => ({
argc: args.length,
args: args.map(a => (a === undefined ? "UNDEFINED" : JSON.stringify(a)))
}));
async function call(label, headers) {
const merged = new Headers([
["Sec-Fetch-Site", "same-origin"],
["X-Server-Function-Instance", "i"]
]);
for (const [name, value] of headers) merged.append(name, value);
const response = await srv.handleServerFunctionRequest(
new Request("https://app.example/_server/data/sink", {
method: "POST",
body: "[1]", // one argument: the number 1
headers: merged
})
);
console.log(label.padEnd(34), response.status, "|", await response.text());
}
console.log("a header sent twice reads back as", JSON.stringify(new Headers([["h", "0"], ["h", "0"]]).get("h")), "\n");
await call("format 0, sent once", [["X-Server-Function-Format", "0"]]);
await call("format 0, sent TWICE", [["X-Server-Function-Format", "0"], ["X-Server-Function-Format", "0"]]);
await call("format 8, sent TWICE", [["X-Server-Function-Format", "8"], ["X-Server-Function-Format", "8"]]);
await call("format 99 (unknown tag)", [["X-Server-Function-Format", "99"]]);
await call("format '' (empty tag)", [["X-Server-Function-Format", ""]]);
await call("no tag, body sent as text/plain", [["content-type", "text/plain;charset=UTF-8"]]);
await call("format 8 (control)", [["X-Server-Function-Format", "8"]]);Output on next (d6a4a52f) — the table above.
Expected behavior
A body the runtime cannot decode is a malformed call and is refused, the way the same body with a usable tag already is. The function is never entered with an argument the caller did not send.
Fix
I tried one — it sits at the argument seam, rather than extractBody's switch — and a regression test that fails without it. Both are noise in an issue, so they are held for a PR rather than pasted here; say the word if you would rather see them inline.
Options
- Refuse at the request seam — the diff above. Smallest, keeps
decodeResponse's documented lenient behaviour untouched, and reuses the existing 400. Says that the body was unusable, not why. - A terminal
default:inextractBody, plus an explicitcase format === BodyFormat.Voidso the "9" tag keeps decoding toundefined. One place, covers every caller — but it changes the response side too, where "resolves undefined for … a raw user Response" is the promised behaviour, so it really means splittingextractBodyinto a strict and a lenient form. - Validate the tag itself: refuse a
BODY_FORMAT_HEADERthat is not aBodyFormatvalue, and a POST body whose content type is not one the runtime sniffs. Same outcome as (1) with a better message, and it catches a decodable but wrong tag that (1) cannot see. More code, and it has to decide 400 vs 415 for the content-type half. - Document it. The failure mode is a function that ran on an argument nobody sent, so this is the worst of the four.
I would take (1) as the fix and treat (3) as the version worth having if the message matters.
Two options this issue owed you. Support the shape rather than refusing it: fetch(url, {method:"POST", body:"[1]"}) sends text/plain, and that is the ordinary hand-written call — sniffing a JSON-looking untagged body as Json is a real alternative to a 400, and it is why so many in-tree tests would otherwise need editing. And push nothing rather than undefined, making it a zero-argument call: probably wrong, since it silently drops data the caller sent, but it belongs in the list.
Related
#3139 is the same header seen from the other side — there the absence of the tag decides the answer's shape; here an unusable value does.
Same family as the request bounds already pinned in server-functions-request-bounds.spec.tsx — #3115 (body size, argument count) and #3119 (decode depth): the runtime declines a call it cannot survive before dispatch. This is the case where it declines to decode and dispatches anyway.
Environment
@solidjs/web |
next @ d6a4a52f |
| Node | v24.19.0 |
| OS | macOS (darwin 25.6.0) |
Activity
fix(web): refuse unusable body formats, keep no-store off 304s, encod…
ryansolid commentedon Aug 31, 2026
Fixed in 93adc02: a POST whose format tag names no decoding — an unknown tag, the duplicate-header comma join, an untagged non-form body — is now refused 400 before dispatch instead of substituting
undefinedfor the body and letting the call commit. Regression tests cover all three shapes, including the"8, 8"duplicate that used to turn the codec's 400 into a 200. Thanks — the duplicate-header observation was the detail that made this a class, not a case.