Skip to content

At the bare address the no-JS answer shape is decided by the absence of a header #3139

Closed

Description

@frenzzy

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 no Sec-Fetch-Site, Origin or Referer at 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 cookie

So 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 === true for 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() in client.ts:794 uses dataAddressFor(base) or serverFunctionDataAddress(...), 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 as fn.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-Cookie goes 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 reports 200 OK for a call whose answer it never received, and that the outcome now sits in that caller's jar at Path=/ until something clears it — if the caller happens to be a browser, the next page render's decodeFlashCookie surfaces 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.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");

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 — its formPost helper (:63), covering 2 tests
  • test/server/server-functions-addressing.spec.tsx — the bound-arguments form post (:166-170), 1 test
  • test/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

  1. 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.
  2. 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.
  3. 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 action query), 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 touching serverFunctionUrl, the rendered action url, and version skew across a deploy.
  4. 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, which server.ts:2344-2346 deliberately wants.
  5. 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

Environment

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

Activity

  1. changed the title [-]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[/+] on Aug 30, 2026
  2. ryansolid commented on Aug 31, 2026

    @ryansolid
    Member

    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.

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