Repository navigation
At the bare address the no-JS answer shape is decided by the absence of a header #3139
Description
Describe the bug
Corrected after filing. The first version of this issue led with
curl -d …and claimed a plain-HTTP script hits this. It does not: with noSec-Fetch-Site,OriginorRefererat all the origin gate answers 403 before dispatch, and my own repro helper was quietly adding those headers. Measured:curl -d (no metadata at all) -> 403 | no cookie browser fetch (sends Sec-Fetch-Site) -> 303 | flash cookie form navigation (Referer only) -> 303 | flash cookieSo the affected caller is same-origin page script, not a service-to-service or CLI caller. The flash lands in that user's own jar — no cross-user leak — and the caller follows the 303 to the referring page's HTML, ending with
response.ok === truefor a call whose answer it never received. That last part is the real complaint, and it stands.
#3094 moved the answer shape onto the url: the data address is the codec protocol, the bare address is plain HTTP, "because shared caches key on the url and store one answer per key — a header-driven shape means one caller kind's cached answer can be replayed to the other" (server.ts:2240-2248). One shape decision at the bare address never got that treatment, and it is still decided by a header — specifically by that header's absence:
// packages/web/server-functions/src/server.ts:1484-1497
/**
* Whether a request is a browser form post — the case the redirect
* convention exists for. A real form sets its own content type and carries
* no `BODY_FORMAT_HEADER`, which only the client runtime sends. Direct HTTP
* callers (curl, a fetch from a script) fall outside it and keep the plain
* response: redirecting them would be nonsense.
*/
function isFormPost(request) {
if (request.method !== "POST" || request.headers.has(BODY_FORMAT_HEADER)) return false;
const type = request.headers.get("content-type") || "";
return (
type.startsWith("application/x-www-form-urlencoded") || type.startsWith("multipart/form-data")
);
}true routes the call into the no-JS convention: 303 See Other, no body, and the outcome written into a Set-Cookie. false leaves it as a plain response with the result in the body. Absence of a header decides which.
The contract is already broken with no proxy involved. The comment says direct HTTP callers "keep the plain response". They do not. A same-origin script posting a form-encoded body — fetch(url, { method: "POST", body: new URLSearchParams(...) }) — sends no format tag, so the bare address answers it as if it were a <form> navigation.
no format tag (what curl sends) -> 200 /checkout
body: "<!doctype html><title>Checkout</title>the page the form was "
receipt reached the caller: NO — it went into a Set-Cookie
with the documented tag (3 = URLSearchParams) -> 200 /_server/charge
body: "{\"receipt\":\"RCPT-abc123\"}"
receipt reached the caller: yes
The caller ends up with 200 OK — a following fetch reports the status of the page it landed on — so response.ok is true, and what it parses as its result is the referring page's HTML. The actual answer went into the caller's cookie jar.
And the shape flips if the tag is lost in transit. A caller that does send the documented tag is only one hop away from the first row. RFC 9110 §7.6.1 requires an intermediary to remove any header the Connection field names, and a header allow-list in front of an app (an API gateway, a WAF, a CDN rule dropping unknown X- headers) does the same to a header it has never heard of. Through a compliant intermediary:
== direct to the origin, bare address /_server/charge ==
format tag present (3 = URLSearchParams) 200 {"charged":true,"receipt":"SECRET-RECEIPT-abc123",...
format tag stripped 303 Set-Cookie flash=… (348 bytes) contains receipt: YES
== through the intermediary, which honours Connection ==
client sends the tag, no Connection hop list 200 {"charged":true,"receipt":"SECRET-RECEIPT-abc123",...
same call, Connection: X-Server-Function-Format 303 Set-Cookie flash=… (348 bytes) contains receipt: YES
== the data address /_server/data/charge (#3094) ==
format tag present 200 {"charged":true,"receipt":"SECRET-RECEIPT-abc123",...
format tag stripped 200 {"charged":true,"receipt":"SECRET-RECEIPT-abc123",...
The third block is the point: the data address is immune, because #3094 put its shape on the url. This decision is the one that was left on a header.
Blast radius, honestly. Narrow, and I want to be precise about why:
- The client transport is not affected. It always calls the data address —
run()inclient.ts:794usesdataAddressFor(base)orserverFunctionDataAddress(...), never the bare one — and dispatch gates the no-JS handler on!scripted(server.ts:2509), which is url-derived. The bare address is only ever produced asfn.url/serverFunctionUrl(...), the value you render into a<form action>. - A genuine no-JS form post is not affected either way: it never carried the tag to begin with.
- What is affected is a direct HTTP caller posting a natural encoding (
URLSearchParams,FormData,File,Blob) to the bare address — a service-to-service call, an integration, a test harness, a script. Today it gets the redirect whether or not it sends the tag, and the runtime's own comment says it should not. - No cross-user leak. The
Set-Cookiegoes back in the response to the caller that made the call; nobody else sees it. The costs are that the result is not in the body where the caller looks, that a following client reports200 OKfor a call whose answer it never received, and that the outcome now sits in that caller's jar atPath=/until something clears it — if the caller happens to be a browser, the next page render'sdecodeFlashCookiesurfaces it there.
So: a correctness and contract bug, not a security one. It needs a stripping intermediary, not a hostile one — and for the first row it needs nothing at all.
Steps to reproduce
# Run against a build of the `next` BRANCH.
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");
srv.registerServerFunction("charge", async (...args) => ({
charged: true,
receipt: "SECRET-RECEIPT-abc123",
sawArgs: args.map(a =>
a instanceof URLSearchParams ? `URLSearchParams(${a.toString()})` : JSON.stringify(a)
)
}));
// ---- origin ----
const origin = createServer(async (incoming, outgoing) => {
const chunks = [];
for await (const c of incoming) chunks.push(c);
const headers = new Headers();
for (const [k, v] of Object.entries(incoming.headers)) if (typeof v === "string") headers.set(k, v);
const response = await srv.handleServerFunctionRequest(
new Request(`http://127.0.0.1:${originPort}${incoming.url}`, {
method: "POST",
body: Buffer.concat(chunks).toString(),
headers
})
);
outgoing.writeHead(response.status, [...response.headers]);
outgoing.end(await response.text());
});
await new Promise(r => origin.listen(0, "127.0.0.1", r));
const originPort = origin.address().port;
// ---- an RFC 9110 §7.6.1-compliant intermediary: it removes the headers
// the Connection field names, then forwards ----
const proxy = createServer((incoming, outgoing) => {
const forwarded = { ...incoming.headers };
for (const name of (forwarded.connection || "").split(",").map(s => s.trim().toLowerCase())) {
if (name && name !== "keep-alive" && name !== "close") delete forwarded[name];
}
delete forwarded.connection;
delete forwarded.host;
const upstream = httpRequest(
{ host: "127.0.0.1", port: originPort, path: incoming.url, method: "POST", headers: forwarded },
res => {
outgoing.writeHead(res.statusCode, res.headers);
res.pipe(outgoing);
}
);
incoming.pipe(upstream);
});
await new Promise(r => proxy.listen(0, "127.0.0.1", r));
const proxyPort = proxy.address().port;
function call(port, path, extra) {
return new Promise(resolve => {
const req = httpRequest(
{
host: "127.0.0.1",
port,
path,
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
referer: `http://127.0.0.1:${port}/checkout`,
"sec-fetch-site": "same-origin",
...extra
}
},
res => {
let body = "";
res.setEncoding("utf8");
res.on("data", c => (body += c));
res.on("end", () =>
resolve({ status: res.statusCode, body, setCookie: (res.headers["set-cookie"] || [])[0] || "" })
);
}
);
req.on("error", e => resolve({ status: 0, body: e.message, setCookie: "" }));
req.end("amount=100");
});
}
const FMT = "X-Server-Function-Format";
function show(label, r) {
const cookie = r.setCookie
? `Set-Cookie flash=… (${r.setCookie.length} bytes) contains receipt: ${
decodeURIComponent(r.setCookie).includes("SECRET-RECEIPT") ? "YES" : "no"
}`
: "no Set-Cookie";
console.log(
`${label.padEnd(52)} ${String(r.status).padEnd(3)} ${r.status === 200 ? r.body.slice(0, 60) : cookie}`
);
}
console.log("== direct to the origin, bare address /_server/charge ==");
show("format tag present (3 = URLSearchParams)", await call(originPort, "/_server/charge", { [FMT]: "3" }));
show("format tag stripped", await call(originPort, "/_server/charge", {}));
console.log("\n== through the intermediary, which honours Connection ==");
show("client sends the tag, no Connection hop list", await call(proxyPort, "/_server/charge", { [FMT]: "3" }));
show("same call, Connection: X-Server-Function-Format", await call(proxyPort, "/_server/charge", { [FMT]: "3", connection: `keep-alive, ${FMT}` }));
console.log("\n== the data address /_server/data/charge (#3094) ==");
show("format tag present", await call(originPort, "/_server/data/charge", { [FMT]: "3" }));
show("format tag stripped", await call(originPort, "/_server/data/charge", {}));
origin.close();
proxy.close();Output on next (d6a4a52f) — the three blocks above.
Expected behavior
The answer shape does not change because a header was removed in transit, and the runtime's rule matches its own stated one: a browser form post gets the redirect convention, a direct HTTP caller keeps the plain response.
Fix
A form post is a navigation, and it says so positively rather than by omission. Measured in Chrome 152 against the same handler:
browser form post sec-fetch-mode: navigate sec-fetch-dest: document
form post targeting an iframe sec-fetch-mode: navigate sec-fetch-dest: iframe
scripted fetch, urlencoded sec-fetch-mode: cors sec-fetch-dest: empty
scripted fetch, FormData sec-fetch-mode: cors sec-fetch-dest: empty
Sec-Fetch-Mode is the right one of the two: it reads navigate for a top-level submit and for one targeting a frame alike, while Sec-Fetch-Dest splits them. Sec-Fetch-* is a forbidden header name, so page script can neither set it nor clear it, and allowsServerFunctionRequest (server.ts:2026-2031) already reads Sec-Fetch-Site — nothing new depends on it.
--- a/packages/web/server-functions/src/server.ts
+++ b/packages/web/server-functions/src/server.ts
@@ -1483,13 +1483,27 @@ let defaultNoJSHandler;
/**
* Whether a request is a browser form post — the case the redirect
- * convention exists for. A real form sets its own content type and carries
- * no `BODY_FORMAT_HEADER`, which only the client runtime sends. Direct HTTP
- * callers (curl, a fetch from a script) fall outside it and keep the plain
- * response: redirecting them would be nonsense.
+ * convention exists for. Direct HTTP callers (curl, a fetch from a script)
+ * fall outside it and keep the plain response: redirecting them would be
+ * nonsense, and it would write their result into a `Set-Cookie` they never
+ * asked for.
+ *
+ * Decided on a POSITIVE signal, not on the absence of one. A form post is a
+ * NAVIGATION — `Sec-Fetch-Mode: navigate`, for a top-level submit and one
+ * targeting a frame alike — while a scripted `fetch` carrying the same
+ * urlencoded or multipart body sends `cors`. `Sec-Fetch-*` is a forbidden
+ * header name, so page script can neither set nor clear it, and the CSRF
+ * gate already reads `Sec-Fetch-Site`. The absence of `BODY_FORMAT_HEADER`
+ * cannot carry this: RFC 9110 §7.6.1 entitles an intermediary to remove a
+ * header the `Connection` field names, and a header allow-list in front of
+ * the app does the same — a stripped tag used to flip a plain answer into a
+ * redirect with the outcome on a cookie. The answer shape belongs to the
+ * request's own nature, the way the codec-vs-HTTP shape belongs to the url
+ * (#3094).
*/
function isFormPost(request) {
if (request.method !== "POST" || request.headers.has(BODY_FORMAT_HEADER)) return false;
+ if (request.headers.get("sec-fetch-mode") !== "navigate") return false;
const type = request.headers.get("content-type") || "";
return (
type.startsWith("application/x-www-form-urlencoded") || type.startsWith("multipart/form-data")Cost, stated plainly, in two parts.
Existing tests: 5, across 3 files. They simulate a browser form post without saying it is a navigation, and need Sec-Fetch-Mode: navigate on the request they build:
test/server/server-functions-redirect-status.spec.tsx— itsformPosthelper (:63), covering 2 teststest/server/server-functions-addressing.spec.tsx— the bound-arguments form post (:166-170), 1 testtest/runtime/cookies.spec.js— the two no-JS form posts (:331,:378)
With those four lines added, both suites are green: the server suite is 45 files and 452 tests (plus 1 expected fail and 2 skipped), and test/runtime/cookies.spec.js matches its pre-change result exactly.
Runtime behaviour: browsers that predate Sec-Fetch-* lose progressive enhancement and fall back to the plain response. That is Chrome < 76 (2019), Firefox < 90 (2021) and Safari < 16.4 (2023). The CSRF gate does not rescue them — it falls through to Origin/Referer (server.ts:2033-2043), which a form post does send — so this is a real, if small, regression for that audience, and an app that needs them can restore the old behaviour by wiring handleNoJS explicitly, which createNoJSHandler's docs already describe as the way to extend the convention. Whether that trade is worth making is a call for a maintainer, not for me, which is why the option below keeps it out.
Options
- Require the navigation signal — the diff above. Fixes the flip for every non-navigating caller and makes the code do what its own comment says. Costs the old-browser audience described above.
- Require it only when the request carries
Sec-Fetch-*at all:dest === null ? <today's rule> : mode === "navigate". Old browsers and curl behave exactly as they do today, and the flip is removed only for callers that do send the headers — which is the browser-originated half. Strictly smaller, and strictly less complete:curl's row in the table stays as it is, contradicting the comment. - Put it on the url, the way A cacheable raw Response is shaped by a request header nothing keys on #3094 did. Give the no-JS convention its own address (or a marker in the rendered
actionquery), so the shape stops depending on any header at all. The strongest answer and the only one that is robust by construction — and the most expensive: a wire-format change touchingserverFunctionUrl, the rendered action url, and version skew across a deploy. - Drop the built-in default and require an integration to wire
handleNoJS. Removes the ambiguity entirely, and removes working-out-of-the-box progressive enhancement, whichserver.ts:2344-2346deliberately wants. - Document it: say in
createNoJSHandler's docs that the bare address answers differently depending on a header intermediaries may remove, and that callers who want the plain response should use the data address. Cheapest, and leaves the contradiction in the code.
I would take (1) if the old-browser cost is acceptable and (2) if it is not; (3) is the one I would want if this is ever touched for another reason anyway.
Regression test
packages/web/test/server/server-functions-nojs-signal.spec.tsx — 3 of its 5 cases fail on next and all 5 pass with the diff. The first describe is deliberately choice-neutral: it pins the invariant (the shape does not depend on the header) and passes under options 1 and 2 alike.
/**
* #3094 moved the ANSWER SHAPE onto the url: the data address is the codec
* protocol, the bare address is plain HTTP, because a header-driven shape
* lets one caller kind's cached answer be replayed to the other. One shape
* decision at the bare address never got that treatment — `isFormPost`,
* which routes a call into the no-JS redirect convention (303 + the outcome
* on a flash cookie) or leaves it as a plain response, and decides on the
* ABSENCE of `X-Server-Function-Format`.
*
* Absence is not a signal. RFC 9110 §7.6.1 entitles any intermediary to
* remove a header the `Connection` field names, and header allow-lists in
* front of an app do the same — so a call the runtime meant to answer with
* a body is answered with a redirect that writes the result into a
* `Set-Cookie` instead. The runtime's own comment says direct HTTP callers
* "keep the plain response"; today what they get depends on a header
* nothing guarantees.
*
* A browser form post is a NAVIGATION and says so positively:
* `Sec-Fetch-Mode: navigate` (measured in Chrome 152 for both a top-level
* form post and one targeting an iframe; a scripted `fetch` with the same
* urlencoded or multipart body sends `cors`). `Sec-Fetch-*` is a forbidden
* header name — page script cannot set or clear it — and the CSRF gate
* already reads `Sec-Fetch-Site`, so nothing new depends on it.
*
* Like the other server-function specs, these run against the built
* bundles (server-functions/dist/*, wired up in vite.config.server.mjs).
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
handleServerFunctionRequest,
registerServerFunction
} from "@solidjs/web/server-functions/server";
const RequestContext = Symbol.for("solid.RequestContext");
const BODY_FORMAT_HEADER = "X-Server-Function-Format";
const URLSEARCHPARAMS_FORMAT = "3";
beforeAll(() => {
(globalThis as any)[RequestContext] = new AsyncLocalStorage();
registerServerFunction("charge", async () => ({ receipt: "RCPT-abc123" }));
});
afterAll(() => {
delete (globalThis as any)[RequestContext];
});
type Caller = "navigate" | "scripted" | "no sec-fetch";
function call(caller: Caller, { tagged }: { tagged: boolean }, path = "/_server/charge") {
const headers = new Headers({
"content-type": "application/x-www-form-urlencoded",
Referer: "https://app.example/checkout"
});
if (caller !== "no sec-fetch") {
headers.set("Sec-Fetch-Site", "same-origin");
headers.set("Sec-Fetch-Mode", caller === "navigate" ? "navigate" : "cors");
headers.set("Sec-Fetch-Dest", caller === "navigate" ? "document" : "empty");
}
if (tagged) headers.set(BODY_FORMAT_HEADER, URLSEARCHPARAMS_FORMAT);
return handleServerFunctionRequest(
new Request(`https://app.example${path}`, { method: "POST", body: "amount=100", headers })
);
}
/** What a caller can observe about the shape it got back. */
async function shape(response: Response) {
const cookie = response.headers.getSetCookie().find(value => value.startsWith("flash="));
return {
status: response.status,
flashed: cookie !== undefined,
receiptInCookie: cookie ? decodeURIComponent(cookie).includes("RCPT-abc123") : false
};
}
describe("the answer shape does not depend on a header an intermediary may remove", () => {
it("answers a scripted caller the same way with and without the tag", async () => {
const tagged = await shape(await call("scripted", { tagged: true }));
const stripped = await shape(await call("scripted", { tagged: false }));
expect(stripped).toEqual(tagged);
});
it("answers a caller with no Sec-Fetch headers the same way either way", async () => {
const tagged = await shape(await call("no sec-fetch", { tagged: true }));
const stripped = await shape(await call("no sec-fetch", { tagged: false }));
expect(stripped).toEqual(tagged);
});
});
describe("who the redirect convention is for", () => {
it("gives a browser form post the redirect and the flash cookie", async () => {
const response = await call("navigate", { tagged: false });
expect(await shape(response)).toEqual({
status: 303,
flashed: true,
receiptInCookie: true
});
});
it("gives a caller that did not navigate a plain response, never a Set-Cookie", async () => {
// The runtime's own words: "Direct HTTP callers (curl, a fetch from a
// script) fall outside it and keep the plain response."
for (const caller of ["scripted", "no sec-fetch"] as Caller[]) {
for (const tagged of [true, false]) {
const response = await call(caller, { tagged });
expect({ caller, tagged, ...(await shape(response)) }).toMatchObject({
status: 200,
flashed: false
});
}
}
});
});
describe("the data address, which #3094 already settled", () => {
it("is unmoved by the tag either way", async () => {
for (const caller of ["scripted", "navigate", "no sec-fetch"] as Caller[]) {
const tagged = await shape(await call(caller, { tagged: true }, "/_server/data/charge"));
const stripped = await shape(await call(caller, { tagged: false }, "/_server/data/charge"));
expect({ caller, ...stripped }).toMatchObject({ status: 200, flashed: false });
expect(stripped).toEqual(tagged);
}
});
});Under option 2 the last test of the second describe needs its "no sec-fetch" row dropped, since that caller keeps today's behaviour by design.
Related
- A cacheable raw Response is shaped by a request header nothing keys on #3094 established that the answer shape belongs on the url rather than a header. This is the decision that did not move.
- The no-JS flash cookie has no size bound — an outcome past the browser's ceiling vanishes silently #3137 is the cookie this writes, and its lack of a size bound. The two are independent (one is when the cookie is written, the other is what fits in it), but they compose: the flip is what puts a direct HTTP caller's result on a cookie in the first place.
Environment
@solidjs/web |
next @ d6a4a52f |
| Node | v24.19.0 |
| Browser measurement | Chrome 152 |
| OS | macOS (darwin 25.6.0) |
Activity
[-]web: at the bare address the no-JS answer shape is decided by the ABSENCE of X-Server-Function-Format[/-][+]At the bare address the no-JS answer shape is decided by the absence of a header[/+]fix(web): key the no-JS convention on Sec-Fetch-Mode, not header abse…
ryansolid commentedon Aug 31, 2026
Fixed in the commit above: the convention is now keyed on the browser's own word for the caller kind.
Sec-Fetch-Mode: navigate— or no fetch metadata at all, for older browsers — keeps the no-JS redirect convention; a form-shaped post carrying a script's mode (cors/same-origin/no-cors) is refused 400 before dispatch (the old shape's real harm was running the mutation and then hiding the outcome), with a dev message pointing at the data address and the format tag. Tagged direct-HTTP callers keep the plain response as documented; answering the script plain instead would have put a second header-decided shape on the bare address — the exact thing #3094 moved onto the url, as you noted. The bare address staying the form address also happens to be the right byte economics: it is the one url that gets serialized into every rendered<form action>, while scripted clients mint the data address in JS where length is free. Your self-correction on the blast radius was appreciated — it is what scoped the fix to the convention gate rather than the origin gate.