Skip to content

The redirect and revalidate headers are unbounded, so a successful mutation can answer with a network error #3131

Closed

Description

@frenzzy

Describe the bug

#3093 bounded the error header because a value past a receiver's limit makes the whole response unreadable. Two more response headers are composed from author-derived content at full length, with no equivalent bound — and both of them break a response that carries a successful mutation.

// packages/web/server-functions/src/server.ts:1379-1385
function maskRedirect(headers, response, requestUrl) {
  const target = response.headers && response.headers.get("Location");
  if (target) {
    headers.set(REDIRECT_HEADER, `${response.status} ${new URL(target, requestUrl)}`);
  }
  headers.delete("Location");
}
// packages/web/src/response.ts:150
  revalidate !== undefined && headers.set(REVALIDATE_HEADER, revalidate.toString());

Measured on next (d6a4a52f): a redirect to a target with a 20,000-character query produces a 20,036-character X-Server-Function-Redirect; a respond(..., { revalidate: [...2000 keys] }) produces a 62,889-character X-Revalidate. Over a real socket both stop being responses:

in process  go     x-server-function-redirect: 20036 characters
in process  reval  x-revalidate: 62889 characters
fetch       go     FETCH FAILED: Headers Overflow Error
fetch       reval  FETCH FAILED: Headers Overflow Error
8 KiB       go     HPE_HEADER_OVERFLOW: Parse Error: Header overflow
8 KiB       reval  HPE_HEADER_OVERFLOW: Parse Error: Header overflow

The write committed. The caller gets a network error.

Where the ceiling actually is. Bisected against the same handler:

receiver redirect target revalidate keys (orders:tenant-42:list:page-N, 28 chars)
undici, fetch's 16 KiB default fails at ~16,100 chars of query fails at 526 keys
an 8 KiB budget (nginx's proxy_buffer_size is one page for the WHOLE upstream header block) fails at ~7,900 chars fails at 261 keys
Chrome 152 passes a 131,107-char header; fails at ~262 KB —

So the browser is not the binding constraint; the proxy in front of the app is, and undici is — which is every Node-side caller, SSR-time included.

That table also separates the two headers honestly:

  • X-Revalidate reaches the ceiling in ordinary use. 261 invalidated cache keys behind a default nginx is not a pathological mutation; a bulk edit or a namespaced cache gets there. This is the half I would call a live defect.
  • The redirect target does not, on its own. A legitimate destination past 8 KB is unusual — nginx's own request-line buffer is 8 KB, so such a URL could not be requested back — and an OAuth state or a search query is normally tens to hundreds of bytes. I did not measure real-world targets and will not claim a number. What reaches the ceiling is a target built from unbounded input: an echoed ?returnTo=, a search string put straight into the redirect, a nested callback chain. That is an app bug today; the runtime's answer to it is a socket error rather than anything the author can see.

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

repro.mjs:

import { AsyncLocalStorage } from "node:async_hooks";
import { createServer, request as httpRequest } from "node:http";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const srv = await import("@solidjs/web/server-functions/server");
const { redirect, respond } = await import("@solidjs/web");

// two successful mutations whose answer carries an author-derived header
srv.registerServerFunction("go", async () => redirect("/search?q=" + "x".repeat(20_000)));
srv.registerServerFunction("reval", async () =>
  respond({ ok: true }, { revalidate: Array.from({ length: 2000 }, (_, i) => `orders:tenant-42:list:page-${i}`) })
);

const server = createServer(async (incoming, outgoing) => {
  const response = await srv.handleServerFunctionRequest(
    new Request(`http://127.0.0.1:${port}${incoming.url}`, {
      method: "POST",
      body: "[]",
      headers: {
        "Sec-Fetch-Site": "same-origin",
        "X-Server-Function-Instance": "i",
        "X-Server-Function-Format": "8"
      }
    })
  );
  const body = response.body ? Buffer.from(await response.arrayBuffer()) : null;
  outgoing.writeHead(response.status, Object.fromEntries(response.headers));
  outgoing.end(body);
});
await new Promise(resolve => server.listen(0, resolve));
const port = server.address().port;

// what the header actually measures, in process
for (const id of ["go", "reval"]) {
  const response = await srv.handleServerFunctionRequest(
    new Request(`http://127.0.0.1:${port}/_server/data/${id}`, {
      method: "POST",
      body: "[]",
      headers: { "Sec-Fetch-Site": "same-origin", "X-Server-Function-Instance": "i", "X-Server-Function-Format": "8" }
    })
  );
  for (const [name, value] of response.headers) {
    if (/redirect|revalidate/i.test(name)) console.log(`in process  ${id.padEnd(6)} ${name}: ${value.length} characters`);
  }
}

// over a real socket: undici (fetch's 16 KiB default)
for (const id of ["go", "reval"]) {
  try {
    const response = await fetch(`http://127.0.0.1:${port}/_server/data/${id}`, { method: "POST", body: "[]" });
    console.log(`fetch       ${id.padEnd(6)} status ${response.status}`);
  } catch (error) {
    console.log(`fetch       ${id.padEnd(6)} FETCH FAILED: ${String(error.cause?.message ?? error.message)}`);
  }
}

// over a real socket: a caller with an 8 KiB budget (nginx's default
// proxy_buffer_size is one page for the WHOLE upstream header block)
const withBudget = (id, maxHeaderSize) =>
  new Promise(resolve => {
    const outgoing = httpRequest({ port, path: `/_server/data/${id}`, method: "POST", maxHeaderSize }, incoming => {
      incoming.resume();
      incoming.on("end", () => resolve(`status ${incoming.statusCode}`));
    });
    outgoing.on("error", error => resolve(`${error.code}: ${error.message}`));
    outgoing.end("[]");
  });
for (const id of ["go", "reval"]) {
  console.log(`8 KiB       ${id.padEnd(6)} ${await withBudget(id, 8 * 1024)}`);
}
server.close();

Output on next (d6a4a52f) — the block above. In process the same calls answer normally; the failure needs a socket, which is everywhere but a test harness.

Expected behavior

A response header the runtime composes cannot cost the response. A value that will not fit is refused with something the author can read, not emitted for the caller's parser to choke on.

What must NOT happen: truncation

The error header is trimmed to fit (boundedErrorHeaderValue) because it is a label — nothing in the runtime reads it, and the structured error travels in the body. These two are load-bearing:

  • a trimmed redirect target is a different address, and the client navigates to it;
  • a trimmed key list is a stale cache, silently.

Between the two failures — a response that will not parse, and one that parses into a wrong destination — I would take the parse failure every time; it is at least loud. So the bound here has to refuse, not cut. Given that, refusing early and legibly is strictly better than either.

Fix

I tried one — it sits at the helpers that produce the values, not maskRedirect — 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

  1. Bound in the producing helpers, refusing — the diff above. Small, routes through the existing error path, no protocol change. Leaves the raw-Response road unbounded.
  2. Bound in maskRedirect, where all redirect roads converge — the durable version of (1), but it first requires the thrown-redirect path inside dispatch's catch to be able to fail without escaping the handler.
  3. Take the values off the header. The redirect is already masked to a 200; its target and the invalidated keys could ride in the body, which has no such ceiling. No bound to pick and nothing to refuse — but X-Revalidate is documented as read by integrations, so this is a protocol change with a real blast radius.
  4. Truncate, like the error header. Rejected above: a cut target is a wrong address and a cut key list is a stale cache. Both fail silently, which is worse than what happens today.
  5. Document a limit and leave it. The failure stays a network error on a mutation that succeeded.

I would take (1) now, (2) as the seam it should end up at, and (3) only if the ceiling turns out to bother anyone at a bound this size.

A cost option 1 does not name. redirect() is @solidjs/web's general navigation helper, not a server-function one, so a bound placed there also applies to ordinary SSR redirects whose Location goes straight to a browser — and browsers tolerate header values far larger than undici or a proxy will. Either scope the bound to the server-function carrier, or accept that it reaches further than the defect does.

Related

#3093 is the same failure on the error header, and the reason its fix does not generalize: that value is a label and could be trimmed, these two cannot. boundedErrorHeaderValue (server.ts:1887) is the shape the bound here deliberately departs from.

Environment

@solidjs/web next @ d6a4a52f
Node v24.19.0
Browser measurement Chrome 152
OS macOS (darwin 25.6.0)

Activity

  1. ryansolid commented on Aug 31, 2026

    @ryansolid
    Member

    Fixed in 817b4d1, taking your option 1 exactly — we implemented it directly to make the release rather than wait on the PR, with your analysis steering every choice; sorry to preempt the code you had ready, and thank you for it. redirect() and the revalidate option now refuse past 4096 characters with a legible error naming the remedy (carry the state server-side; split the invalidation or use coarser keys). Refusal, not truncation, for exactly your two reasons: a trimmed target is a different address, a trimmed key list is a silently stale cache. The bound sits in the producing helpers, which run inside the function body — so both the returned and thrown spellings land on dispatch's ordinary error path (the dispatch-level tests pin that what leaves the handler is the error shape with every header under 4096, not an HPE_HEADER_OVERFLOW pointing at nothing). Your 261-keys-behind-default-nginx measurement is pinned as the regression shape. Scope matches your option 1: the raw-Response-with-oversized-Location road stays the author's own; the maskRedirect seam (your option 2) can come later if it ever matters in practice.

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