Repository navigation
An unbounded error message in the response header makes the response unreadable #3093
Description
Describe the bug
The error header carries the failure's message with no bound on its length. Past the receiver's header limit the response stops being readable at all, so a caller gets a network error in place of the application error it was meant to see.
// packages/web/server-functions/src/server.ts
const error = safe instanceof Error ? safe.message : typeof safe === "string" ? safe : "true";
headers.set(ERROR_HEADER, encodeErrorHeaderValue(error));The comment two lines above already says where the real value lives — "the structured error still travels in the body" — and the client agrees: serverFunctionFailure(response, value) throws the decoded body and reads the header only as a flag. So the header is a label, and nothing about the failure is lost if it is capped.
Two ways to reach it:
- development, for any thrown error, since messages are not sanitized there — which is exactly when someone is reading errors;
- production, for anything branded with
markSafeError, whose message survives sanitization by design.
Percent-encoding makes the ceiling lower than it looks: a Cyrillic or emoji message expands roughly ninefold, so ~1.8 KB of text is enough to cross undici's 16 KB, and nginx's default 4–8 KB header buffers cross sooner.
Steps to reproduce
mkdir sf-header && cd sf-header && npm init -y
npm i @solidjs/web@2.0.0-rc.4
node repro.mjsrepro.mjs:
import { AsyncLocalStorage } from "node:async_hooks";
import { createServer } from "node:http";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const { handleServerFunctionRequest, registerServerFunction } = await import("@solidjs/web/server-functions/server");
const { markSafeError } = await import("@solidjs/web");
registerServerFunction("boom", async () => {
throw markSafeError(new Error("E".repeat(32 * 1024)));
});
const server = createServer(async (req, res) => {
const out = await handleServerFunctionRequest(
new Request(`http://127.0.0.1:${server.address().port}${req.url}`, {
method: req.method,
headers: { "Sec-Fetch-Site": "same-origin", "X-Server-Function-Instance": "i" }
})
);
res.writeHead(out.status, Object.fromEntries(out.headers));
res.end(out.body ? Buffer.from(await out.arrayBuffer()) : null);
});
await new Promise(r => server.listen(0, r));
try {
const response = await fetch(`http://127.0.0.1:${server.address().port}/_server/boom`, { method: "POST" });
console.log("status", response.status);
} catch (error) {
console.log("FETCH FAILED:", String(error.cause?.message ?? error.message));
}
server.close();Output:
FETCH FAILED: Headers Overflow Error
In-process the same call answers normally, so this only shows up once a real socket is in between — which is everywhere but a test harness.
Expected behavior
A long error message should cost detail in the header, not the whole response.
Options
Worth stating first what the header's value is for, because I had to go looking: nothing in the runtime reads it. The client checks only that it is present and throws the decoded body; decodeErrorHeaderValue is exported from both entries but never called, in this repo or in solid-router; and RFC 10 mentions the header once, as an "error tag", not as a carrier of the message.
- Stop putting the message there. Send a constant, and let the body stay the only carrier — which is already the case for everything that reads it. No budget to pick, no trimming that has to happen before encoding to avoid splitting a percent escape, and
encodeErrorHeaderValue/decodeErrorHeaderValuelose their reason to exist: that machinery, the=?1?marker and the lone-surrogate well-forming, is there only because an arbitrary message goes into a latin1 ByteString. Retiring the pair is a public-API removal, so it can follow separately from the fix. - Cap it inside
encodeErrorHeaderValue. Keeps a readable label in access logs and on proxies — the one thing (1) gives up — at the cost of a budget and a trim that has to be applied to the source, not to the encoded form. - Cap at the call site in
server.ts, before the encoder sees the message. Smaller than (2), but covers only this producer.
(1) if a label in the logs is not worth defending; (2) if it is. What decides it is whether anyone is meant to read the value at all, which is a question for you rather than for me.
Environment
@solidjs/web |
2.0.0-rc.4 (published), and next |
| Node | v24.19.0 |
| OS | macOS (darwin 25.6.0) |
Activity
frenzzy commentedon Aug 29, 2026
Corrected the options above: I had written that RFC 10 documents the header as carrying the message. It does not — the one mention calls it an error tag. And
decodeErrorHeaderValueis exported from both entries but never called, here or in the router, so nothing reads the value today.That reorders the fix. Dropping the message and sending a constant is simpler than capping it: no budget, no trim-before-encode, and the percent-encoding machinery around it exists only to carry an arbitrary message through a latin1 header. Capping is the answer only if a readable label in access logs is worth keeping.
fix(web): bound the error header value, support null-body statuses (#…
frenzzy commentedon Aug 29, 2026
Fixed by 19fa8b0 — verified on
ec523607by re-running the repro above rather than by reading the commit.The 32 KB message now answers
status 200instead ofFETCH FAILED: Headers Overflow Error. Measured what the bound actually does:Slicing the source rather than the encoded value is the right call — cutting the encoding would split a percent escape and leave it undecodable. Nothing is lost either way, since the structured error still travels in the body.
For anyone finding this later: the header value stayed a label. Option (1) — dropping the message entirely — was not taken, so
decodeErrorHeaderValueremains exported and uncalled.