Repository navigation
The redirect and revalidate headers are unbounded, so a successful mutation can answer with a network error #3131
Description
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-Revalidatereaches 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
stateor 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.mjsrepro.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
- Bound in the producing helpers, refusing — the diff above. Small, routes through the existing error path, no protocol change. Leaves the raw-
Responseroad unbounded. - Bound in
maskRedirect, where all redirect roads converge — the durable version of (1), but it first requires the thrown-redirect path inside dispatch'scatchto be able to fail without escaping the handler. - 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-Revalidateis documented as read by integrations, so this is a protocol change with a real blast radius. - 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.
- 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
fix(web): refuse oversized redirect targets and revalidate lists at t…
ryansolid commentedon Aug 31, 2026
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 therevalidateoption 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; themaskRedirectseam (your option 2) can come later if it ever matters in practice.