Skip to content

Commit be7bcd2

Browse files
fix(web): key the no-JS convention on Sec-Fetch-Mode, not header absence (#3139)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ecfee20 commit be7bcd2

3 files changed

Lines changed: 130 additions & 13 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
The bare server-function address no longer decides its answer shape by the
6+
absence of a header (#3139). The no-JS redirect convention (303, outcome
7+
in the flash cookie) engaged on shape alone — form content type, no format
8+
tag — which a page script's `fetch(url, { body: new URLSearchParams(...) })`
9+
also matches: the script followed the 303 to the referrer's HTML, read
10+
`response.ok === true`, and its answer disappeared into a cookie it would
11+
never look at. Dispatch now reads the browser's own word for the caller
12+
kind: `Sec-Fetch-Mode: navigate` (or no fetch metadata, for older
13+
browsers) keeps the convention, while a script's form-shaped post is
14+
refused 400 before dispatch — before the mutation runs — pointing at the
15+
data address and the format tag, the two spellings that work. Tagged
16+
direct-HTTP callers keep the plain response as documented.

packages/web/server-functions/src/server.ts

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,11 +1513,13 @@ export function createNoJSHandler({ base = "" } = {}) {
15131513
let defaultNoJSHandler;
15141514

15151515
/**
1516-
* Whether a request is a browser form post — the case the redirect
1517-
* convention exists for. A real form sets its own content type and carries
1518-
* no `BODY_FORMAT_HEADER`, which only the client runtime sends. Direct HTTP
1519-
* callers (curl, a fetch from a script) fall outside it and keep the plain
1520-
* response: redirecting them would be nonsense.
1516+
* Whether a request is form-SHAPED: a POST with a form content type and no
1517+
* `BODY_FORMAT_HEADER` (which only the client runtime sends). Shape alone
1518+
* is not the convention's gate — a page script's fetch can be form-shaped
1519+
* too — so dispatch additionally reads `Sec-Fetch-Mode` to keep the
1520+
* redirect convention on actual form navigations (#3139). Tagged direct
1521+
* HTTP callers fall outside the shape test entirely and keep the plain
1522+
* response.
15211523
*/
15221524
function isFormPost(request) {
15231525
if (request.method !== "POST" || request.headers.has(BODY_FORMAT_HEADER)) return false;
@@ -2420,14 +2422,39 @@ export async function handleServerFunctionRequest(request, options = {}) {
24202422
// Same fallback, then the built-in convention: an unconfigured app still
24212423
// gets working progressive enhancement for real form posts, while direct
24222424
// HTTP calls keep the plain response.
2423-
const handleNoJS =
2424-
options.handleNoJS !== undefined
2425-
? options.handleNoJS
2426-
: config.handleNoJS !== undefined
2427-
? config.handleNoJS
2428-
: isFormPost(request)
2429-
? defaultNoJSHandler || (defaultNoJSHandler = createNoJSHandler())
2430-
: undefined;
2425+
//
2426+
// The convention is for form NAVIGATIONS — the browser follows the 303
2427+
// and the flash cookie carries the outcome to the next render. Which
2428+
// caller kind this is was decided by the ABSENCE of a header (#3139,
2429+
// the shape-on-the-url doctrine's one leftover): a same-origin page
2430+
// script posting a form-encoded body — fetch(url, { body: new
2431+
// URLSearchParams(...) }) — is form-shaped too, and routing IT into the
2432+
// convention lands it on the referrer's HTML with `response.ok === true`
2433+
// while its answer disappears into its own cookie jar. The browser's own
2434+
// word tells the two apart: a real form navigation sends `Sec-Fetch-Mode:
2435+
// navigate` (or nothing, on older browsers), a script's fetch never does.
2436+
// The script's call is refused as malformed — BEFORE dispatch, because
2437+
// the old behavior's real harm was running the mutation and then hiding
2438+
// the outcome — pointing at the two spellings that work. Answering it
2439+
// plain instead would put a second, header-decided shape on the bare
2440+
// address, which is the exact thing #3094 moved onto the url.
2441+
let handleNoJS = options.handleNoJS !== undefined ? options.handleNoJS : config.handleNoJS;
2442+
if (handleNoJS === undefined && !scripted && isFormPost(request)) {
2443+
const fetchMode = request.headers.get("Sec-Fetch-Mode");
2444+
if (fetchMode === null || fetchMode === "navigate") {
2445+
handleNoJS = defaultNoJSHandler || (defaultNoJSHandler = createNoJSHandler());
2446+
} else {
2447+
const response = new Response(
2448+
DEV
2449+
? "The bare server-function address answers form navigations with the " +
2450+
"no-JS redirect convention. Scripted callers use the data address " +
2451+
`(…/data/${functionId}) or send the ${BODY_FORMAT_HEADER} tag.`
2452+
: null,
2453+
{ status: 400 }
2454+
);
2455+
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
2456+
}
2457+
}
24312458
// single-flight is scripted-client opt-in: the caller sends the request
24322459
// header naming the sources it can consume ("true" is the unnamed hook's
24332460
// reserved id), the server must have hooks to produce the data. Only

packages/web/test/server/server-functions-redirect-status.spec.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,80 @@ describe("the no-JS form convention honors returned redirects (#3096)", () => {
279279
});
280280
});
281281

282+
describe("the bare address reads the caller kind off Sec-Fetch-Mode (#3139)", () => {
283+
// The no-JS convention — 303, outcome in a flash cookie — exists for
284+
// form NAVIGATIONS. It used to engage on shape alone (form content type,
285+
// no format tag), which a page script's fetch(url, { body: new
286+
// URLSearchParams(...) }) also matches: the script got the 303, followed
287+
// it to the referrer's HTML, read `response.ok === true`, and its answer
288+
// sat in a cookie it would never look at. The browser's own word tells
289+
// the callers apart: navigations send `Sec-Fetch-Mode: navigate` (or
290+
// nothing, on older browsers), a script's fetch never does.
291+
function formShaped(id: string, mode?: string) {
292+
return new Request(`https://app.example/_server/${id}`, {
293+
method: "POST",
294+
headers: {
295+
"Sec-Fetch-Site": "same-origin",
296+
"Content-Type": "application/x-www-form-urlencoded",
297+
Referer: "https://app.example/current-page",
298+
...(mode ? { "Sec-Fetch-Mode": mode } : {})
299+
},
300+
body: "a=1"
301+
});
302+
}
303+
304+
it("keeps the convention for navigations, with or without fetch metadata", async () => {
305+
registerServerFunction("nojs-mode-nav", async () => ({ saved: true }));
306+
307+
// a modern browser's form navigation declares itself
308+
const declared = await handleServerFunctionRequest(formShaped("nojs-mode-nav", "navigate"));
309+
expect(declared.status).toBe(303);
310+
expect(declared.headers.get("Set-Cookie")).toContain("flash=");
311+
312+
// an older browser sends no fetch metadata at all: same convention
313+
const bare = await handleServerFunctionRequest(formShaped("nojs-mode-nav"));
314+
expect(bare.status).toBe(303);
315+
});
316+
317+
it("refuses a page script's form-shaped post before the function runs", async () => {
318+
const fn = vi.fn(async () => ({ receipt: "SECRET" }));
319+
registerServerFunction("nojs-mode-script", fn);
320+
321+
for (const mode of ["cors", "same-origin", "no-cors"]) {
322+
const response = await handleServerFunctionRequest(formShaped("nojs-mode-script", mode));
323+
// refused as malformed — not a 303 whose answer vanishes into the
324+
// caller's cookie jar, and not a 200 wearing the referrer's HTML
325+
expect([mode, response.status]).toEqual([mode, 400]);
326+
expect(response.headers.get("Set-Cookie")).toBeNull();
327+
}
328+
// before dispatch: the old shape's real harm was running the mutation
329+
// and then hiding the outcome
330+
expect(fn).not.toHaveBeenCalled();
331+
});
332+
333+
it("a tagged script call at the bare address keeps the plain response", async () => {
334+
// the documented direct-HTTP spelling: the format tag takes the call
335+
// out of the form shape entirely, whatever its fetch mode says
336+
registerServerFunction("nojs-mode-tagged", async (params: URLSearchParams) => ({
337+
got: params.get("a")
338+
}));
339+
const response = await handleServerFunctionRequest(
340+
new Request("https://app.example/_server/nojs-mode-tagged", {
341+
method: "POST",
342+
headers: {
343+
"Sec-Fetch-Site": "same-origin",
344+
"Sec-Fetch-Mode": "cors",
345+
"Content-Type": "application/x-www-form-urlencoded",
346+
"X-Server-Function-Format": "3" // URLSearchParams
347+
},
348+
body: "a=1"
349+
})
350+
);
351+
expect(response.status).toBe(200);
352+
expect(await response.json()).toEqual({ got: "1" });
353+
});
354+
});
355+
282356
/**
283357
* The mask is justified by ONE fact — that fetch follows these statuses —
284358
* so it has to cover exactly the set fetch follows. Exercising 302 alone

0 commit comments

Comments
 (0)