Skip to content

An unbounded error message in the response header makes the response unreadable #3093

Closed

Description

@frenzzy

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.mjs

repro.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.

  1. 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 / decodeErrorHeaderValue lose 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.
  2. 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.
  3. 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

  1. frenzzy commented on Aug 29, 2026

    @frenzzy
    ContributorAuthor

    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 decodeErrorHeaderValue is 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.

  2. frenzzy commented on Aug 29, 2026

    @frenzzy
    ContributorAuthor

    Fixed by 19fa8b0 — verified on ec523607 by re-running the repro above rather than by reading the commit.

    The 32 KB message now answers status 200 instead of FETCH FAILED: Headers Overflow Error. Measured what the bound actually does:

    source              | header    | full message still in body
    18 chars latin      |  18 bytes | yes
    2000 chars latin    | 256 bytes | yes
    517 chars Cyrillic  | 760 bytes | yes
    800 emoji           | 772 bytes | yes
    

    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 decodeErrorHeaderValue remains exported and uncalled.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions