You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The no-JS flash cookie's degrade ladder does not bound the cookie. It drops input and bounds result, but never touches url — and url is pathname + search of the request the caller chose, so a form whose action carries state (<form action={fn.url + "?return=" + here}>, this convention's own idiom) puts the payload past the ~4096-byte ceiling on the url alone, with every rung already spent. The browser then discards the whole Set-Cookie — no signal in the response, the console, or server-side — and the page after the redirect is indistinguishable from one where nothing was submitted, which is exactly the #3137 harm the ladder exists to prevent, after the mutation has committed. The payload it returns carries truncated: true, which is the encoder asserting it degraded to fit about a cookie that did not fit.
Reproduction
packages/web/flash-url-repro.mjs, run from packages/web after pnpm build (it imports the built server-functions bundle, the same artifact the server specs run against):
// node flash-url-repro.mjsimport{encodeFlashCookie}from"./server-functions/dist/server.js";constCEILING=4096;// RFC 6265bis §5.6: the browser measures name=valueconstpair=c=>(c.includes("; ") ? c.slice(0,c.indexOf("; ")) : c);constback="/catalog/"+"a".repeat(4200);// the page the form wants to return toconstrows={rows: Array.from({length: 2000},(_,i)=>({id: i,sku: `SKU-${i}`}))};for(const[label,url,result]of[["CONTROL short url, 2000-row result","/_server/publish?return=%2Fcatalog",rows],[" long url, small result","/_server/publish?return="+encodeURIComponent(back),{published: true}],[" long url, 2000-row result","/_server/publish?return="+encodeURIComponent(back),rows]]){constp=pair(encodeFlashCookie(url,result,[]));constpayload=JSON.parse(decodeURIComponent(p.slice(p.indexOf("=")+1)));console.log(`${label}\n name=value: ${String(p.length).padStart(5)} bytes -> browser `+`${p.length<=CEILING ? "STORES it" : "DISCARDS the whole Set-Cookie"}\n`+` payload: truncated=${payload.truncated} input=${JSON.stringify(payload.input)} `+`result=${JSON.stringify(payload.result)} url=${payload.url.length} bytes\n`);}
Actual output at f0f7531b:
CONTROL short url, 2000-row result
name=value: 189 bytes -> browser STORES it
payload: truncated=true input=[] result=true url=34 bytes
long url, small result
name=value: 4394 bytes -> browser DISCARDS the whole Set-Cookie
payload: truncated=true input=[] result=true url=4237 bytes
long url, 2000-row result
name=value: 4394 bytes -> browser DISCARDS the whole Set-Cookie
payload: truncated=true input=[] result=true url=4237 bytes
The CONTROL row is the ladder working as designed: a 2000-row result is 100+ KB of JSON, the input echo is dropped, the structured result reduces to the outcome flag true, and 189 bytes reach the browser. The two long-url rows are the same ladder, fully spent, returning 4394 bytes — the identical size in both, because result has already collapsed to true and url is all that is left. truncated is true in all three.
The threshold, measured by bisection on the same build: the first plain-ASCII url that overruns is 3940 bytes long (pair 4097). Percent-encoding lowers it — each / in an encoded return path costs three bytes of the budget.
End to end through the handler, the number is the same order: a POST to /_server/<id>?return=<4200-byte encoded path> returns a 303 whose flash pair is 4410 bytes, after the registered function has already run once.
Where
At f0f7531b:
packages/web/server-functions/src/flash.ts:114-126 — the ladder. payload.truncated = true, then payload.input = [], then the result prefix/flag rung, then return flashCookie(payload). payload.url is never read.
packages/web/server-functions/src/flash.ts:112-113 — the comment that states the contract the code does not keep: "url and the error/thrown flags always survive: what happened, and to which submission, is the part that must not be lost."
packages/web/server-functions/src/server.ts:2052 — the write site, and why url is not the encoder's to choose: encodeFlashCookie(url.pathname + url.search, result, args, thrown).
Provenance: all three sites arrived in one commit.
ecfee20d (2026-08-31) is the #3137 fix itself. Before it there was no ladder, no budget, and no truncated flag: an oversized cookie was simply dropped, and nothing claimed otherwise. The fix bounded two of the payload's three variable-length fields and introduced the flag that now asserts success over the third. So this is not a regression in behaviour for a long-url payload — that payload was dropped before the fix too — but the false truncated: true on it is new with the fix, and the fix is where the ladder was supposed to be complete.
Why it matters
The flash cookie is the only channel a no-JS form submission has for its outcome. When it is discarded, the next render sees nothing and the page looks like one where the form was never submitted. The mutation has already committed by the time encodeFlashCookie runs, so the natural user response — submit again — is a second write against a handler that may not be idempotent. That is the whole reason #3137 exists; this is the case #3137's ladder does not cover.
The honest limits:
It needs the app to build a long url. The realistic shape is a form action that carries return state, a filter set, or a cursor: action={fn.url + "?return=" + encodeURIComponent(location.pathname + location.search)}. Under ~3900 bytes of url nothing changes. Apps whose actions are bare fn.url are never affected.
It is not attacker-reachable directly. The CSRF gate (server.ts, Sec-Fetch-Site of same-site/cross-site/none is rejected) refuses a cross-site form post, so a third party cannot simply aim a 40 KB query string at the endpoint and get a cookie written. The narrow escalation that does exist is second-order and needs the app's cooperation: a route that accepts an arbitrarily long same-origin path, a page on it that reflects that path into a form action, and a victim who follows a crafted link and then submits. That chain is plausible for a catalog- or search-shaped route; it is not a general CSRF.
truncated: true on a discarded cookie is not itself observable by the user — the cookie is gone, so nothing reads the flag. Its cost is to anyone reasoning about the encoder: the field that means "degraded to fit" is set on the one payload that did not, so the contract cannot be trusted at its only interesting boundary, and an integration or test that checks it is checking nothing.
No browser is required to see the encoder half. The repro above is node; only the "discarded whole" half is browser behaviour, and that half is RFC 6265bis §5.6, not a quirk.
Options
Bound url as the ladder's last rung. Reuse the existing halving-prefix walk, applied to url only after input and result are spent. Trade-off: the next render may receive a prefix of the url, and the integrations that do submission.url === here or url.startsWith("/") to decide whether the outcome belongs to the page they are rendering will not match it. A truncated url is a worse identifier than a whole one — but it is a better outcome than no cookie at all, which is the thing The no-JS flash cookie has no size bound — an outcome past the browser's ceiling vanishes silently #3137 was written to stop, and the failure is visible (the render shows "succeeded, too large to display" against the wrong page) rather than silent.
Drop url entirely on the last rung. Simpler, and avoids handing an integration a plausible-looking wrong url. Trade-off: the payload then has no submission identity at all, so a page rendering two forms cannot tell which one the outcome belongs to, and decodeFlashCookie would have to accept a payload without url — which conflicts with treating url's presence as the readability test.
Return no cookie and report the overrun. Have encodeFlashCookie return undefined (or throw) when nothing fits, and let server.ts decide — log, or fall back to a redirect the integration can read. Trade-off: it is honest, and it makes the failure loud in development, but it changes the function's signature and gives the user no better outcome in production than they have today.
Leave the runtime alone and document the ceiling. Say in the encodeFlashCookie doc comment and the no-JS guide that the call url counts against the 4 KB budget and that long return state belongs in a session, not the action. Trade-off: zero runtime cost and zero behaviour change, but it puts the burden on every integration author to know a limit that only manifests as a page where nothing happened.
Recommendation: option 1, with option 4's doc note alongside it. It keeps the ladder's existing shape and its existing promise ("degrade rather than vanish"), it adds one if and reuses the prefix routine the result rung already needs — the halving walk factors out to boundedPrefix(payload, field) and both rungs call it, so the change is net-simpler than the two copies would be — and it does not add a signature, a config knob, or a second code path. Every payload that fits today is untouched: the new rung is guarded by !fitsCookie(payload), which is false for all of them.
The part that is genuinely the maintainer's call is whether a prefix of the url is the right last resort or whether dropping it (option 2) is, since that turns on how much weight the url field is meant to carry as submission identity for integrations. And if the position is that a 4 KB action url is an app bug rather than a runtime concern, option 4 alone is a defensible answer — it just needs to be stated somewhere, because right now the code states the opposite.
Regression test
packages/web/test/server/server-functions-flash-url-bound.spec.tsx, which runs against the built bundles like the other server-function specs:
/** RFC 6265bis §5.6: what the browser measures is the name=value pair. */constCOOKIE_CEILING=4096;functionpairOf(setCookie: string){constend=setCookie.indexOf("; ");returnend<0 ? setCookie : setCookie.slice(0,end);}describe("the flash cookie's url is bounded like everything else in it",()=>{it("keeps the pair storable when the url alone overruns the ceiling",()=>{consturl="/_server/publish?return="+encodeURIComponent("/catalog/"+"a".repeat(4200));constcookie=encodeFlashCookie(url,{published: true},[]);constpair=pairOf(cookie);expect(pair.length,`${pair.length} bytes — the browser discards the whole cookie`).toBeLessThanOrEqual(COOKIE_CEILING);// and having fit, it still says what happenedexpect(decodeFlashCookie(pair)?.truncated).toBe(true);});it("never reports truncated success for a payload that cannot be stored",()=>{// the ladder has already spent input and result here; `truncated: true`// is the encoder's own claim that what it returned fitsconsturl="/_server/import?"+"q="+"b".repeat(40000);constpair=pairOf(encodeFlashCookie(url,{rows: 12000},[{big: "c".repeat(50000)}]));constpayload=JSON.parse(decodeURIComponent(pair.slice(FLASH_COOKIE.length+1)));expect(payload.truncated).toBe(true);expect(pair.length,"the payload claims it was degraded to fit, and did not").toBeLessThanOrEqual(COOKIE_CEILING);});it("still tells the next render that a long-url submission FAILED",()=>{consturl="/_server/charge?receipt="+"d".repeat(4200);constpair=pairOf(encodeFlashCookie(url,newError("card declined"),[],true));expect(pair.length).toBeLessThanOrEqual(COOKIE_CEILING);constsubmission=decodeFlashCookie(pair);expect(submission?.error).toBeInstanceOf(Error);expect(submission?.result).toBeUndefined();});it("holds through the handler, where the url is not the encoder's to choose",async()=>{letran=0;registerServerFunction("flash-url-bound-publish",async()=>{ran++;return{published: true};});constback="/catalog/"+"e".repeat(4200);constresponse=awaithandleServerFunctionRequest(newRequest("https://app.example/_server/flash-url-bound-publish?return="+encodeURIComponent(back),{method: "POST",headers: {"Sec-Fetch-Site": "same-origin","Content-Type": "application/x-www-form-urlencoded",Referer: "https://app.example/catalog"},body: "qty=1"}));expect(ran).toBe(1);// the mutation COMMITTED — this is the #3137 caseexpect(response.status).toBe(303);constflash=response.headers.getSetCookie().find(entry=>entry.startsWith(`${FLASH_COOKIE}=`))!;constpair=pairOf(flash);expect(pair.length,`${pair.length} bytes reach the browser and none come back`).toBeLessThanOrEqual(COOKIE_CEILING);});});
Against f0f7531b all four go red on the size assertion — 4394, 40171, 4375 and 4410 bytes against the 4096 ceiling — with the third and fourth showing that the outcome and the committed mutation are what is being thrown away. The spec deliberately does not pin how the ladder pays for the url: it pins that a payload the encoder marks truncated is a payload that fits, and that the error/thrown flags — the part the ladder's own comment says must never be lost — still arrive.
Summary
The no-JS flash cookie's degrade ladder does not bound the cookie. It drops
inputand boundsresult, but never touchesurl— andurlispathname + searchof the request the caller chose, so a form whose action carries state (<form action={fn.url + "?return=" + here}>, this convention's own idiom) puts the payload past the ~4096-byte ceiling on the url alone, with every rung already spent. The browser then discards the wholeSet-Cookie— no signal in the response, the console, or server-side — and the page after the redirect is indistinguishable from one where nothing was submitted, which is exactly the #3137 harm the ladder exists to prevent, after the mutation has committed. The payload it returns carriestruncated: true, which is the encoder asserting it degraded to fit about a cookie that did not fit.Reproduction
packages/web/flash-url-repro.mjs, run frompackages/webafterpnpm build(it imports the built server-functions bundle, the same artifact the server specs run against):Actual output at
f0f7531b:The CONTROL row is the ladder working as designed: a 2000-row result is 100+ KB of JSON, the input echo is dropped, the structured result reduces to the outcome flag
true, and 189 bytes reach the browser. The two long-url rows are the same ladder, fully spent, returning 4394 bytes — the identical size in both, becauseresulthas already collapsed totrueandurlis all that is left.truncatedistruein all three.The threshold, measured by bisection on the same build: the first plain-ASCII url that overruns is 3940 bytes long (pair 4097). Percent-encoding lowers it — each
/in an encoded return path costs three bytes of the budget.End to end through the handler, the number is the same order: a
POSTto/_server/<id>?return=<4200-byte encoded path>returns a 303 whose flash pair is 4410 bytes, after the registered function has already run once.Where
At
f0f7531b:packages/web/server-functions/src/flash.ts:114-126— the ladder.payload.truncated = true, thenpayload.input = [], then theresultprefix/flag rung, thenreturn flashCookie(payload).payload.urlis never read.packages/web/server-functions/src/flash.ts:112-113— the comment that states the contract the code does not keep: "urland the error/thrown flags always survive: what happened, and to which submission, is the part that must not be lost."packages/web/server-functions/src/server.ts:2052— the write site, and whyurlis not the encoder's to choose:encodeFlashCookie(url.pathname + url.search, result, args, thrown).Provenance: all three sites arrived in one commit.
ecfee20d(2026-08-31) is the #3137 fix itself. Before it there was no ladder, no budget, and notruncatedflag: an oversized cookie was simply dropped, and nothing claimed otherwise. The fix bounded two of the payload's three variable-length fields and introduced the flag that now asserts success over the third. So this is not a regression in behaviour for a long-url payload — that payload was dropped before the fix too — but the falsetruncated: trueon it is new with the fix, and the fix is where the ladder was supposed to be complete.Why it matters
The flash cookie is the only channel a no-JS form submission has for its outcome. When it is discarded, the next render sees nothing and the page looks like one where the form was never submitted. The mutation has already committed by the time
encodeFlashCookieruns, so the natural user response — submit again — is a second write against a handler that may not be idempotent. That is the whole reason #3137 exists; this is the case #3137's ladder does not cover.The honest limits:
action={fn.url + "?return=" + encodeURIComponent(location.pathname + location.search)}. Under ~3900 bytes of url nothing changes. Apps whose actions are barefn.urlare never affected.server.ts,Sec-Fetch-Siteofsame-site/cross-site/noneis rejected) refuses a cross-site form post, so a third party cannot simply aim a 40 KB query string at the endpoint and get a cookie written. The narrow escalation that does exist is second-order and needs the app's cooperation: a route that accepts an arbitrarily long same-origin path, a page on it that reflects that path into a form action, and a victim who follows a crafted link and then submits. That chain is plausible for a catalog- or search-shaped route; it is not a general CSRF.truncated: trueon a discarded cookie is not itself observable by the user — the cookie is gone, so nothing reads the flag. Its cost is to anyone reasoning about the encoder: the field that means "degraded to fit" is set on the one payload that did not, so the contract cannot be trusted at its only interesting boundary, and an integration or test that checks it is checking nothing.node; only the "discarded whole" half is browser behaviour, and that half is RFC 6265bis §5.6, not a quirk.Options
Bound
urlas the ladder's last rung. Reuse the existing halving-prefix walk, applied tourlonly afterinputandresultare spent. Trade-off: the next render may receive a prefix of the url, and the integrations that dosubmission.url === hereorurl.startsWith("/")to decide whether the outcome belongs to the page they are rendering will not match it. A truncated url is a worse identifier than a whole one — but it is a better outcome than no cookie at all, which is the thing The no-JS flash cookie has no size bound — an outcome past the browser's ceiling vanishes silently #3137 was written to stop, and the failure is visible (the render shows "succeeded, too large to display" against the wrong page) rather than silent.Drop
urlentirely on the last rung. Simpler, and avoids handing an integration a plausible-looking wrong url. Trade-off: the payload then has no submission identity at all, so a page rendering two forms cannot tell which one the outcome belongs to, anddecodeFlashCookiewould have to accept a payload withouturl— which conflicts with treatingurl's presence as the readability test.Return no cookie and report the overrun. Have
encodeFlashCookiereturnundefined(or throw) when nothing fits, and letserver.tsdecide — log, or fall back to a redirect the integration can read. Trade-off: it is honest, and it makes the failure loud in development, but it changes the function's signature and gives the user no better outcome in production than they have today.Leave the runtime alone and document the ceiling. Say in the
encodeFlashCookiedoc comment and the no-JS guide that the call url counts against the 4 KB budget and that long return state belongs in a session, not the action. Trade-off: zero runtime cost and zero behaviour change, but it puts the burden on every integration author to know a limit that only manifests as a page where nothing happened.Recommendation: option 1, with option 4's doc note alongside it. It keeps the ladder's existing shape and its existing promise ("degrade rather than vanish"), it adds one
ifand reuses the prefix routine theresultrung already needs — the halving walk factors out toboundedPrefix(payload, field)and both rungs call it, so the change is net-simpler than the two copies would be — and it does not add a signature, a config knob, or a second code path. Every payload that fits today is untouched: the new rung is guarded by!fitsCookie(payload), which is false for all of them.The part that is genuinely the maintainer's call is whether a prefix of the url is the right last resort or whether dropping it (option 2) is, since that turns on how much weight the
urlfield is meant to carry as submission identity for integrations. And if the position is that a 4 KB action url is an app bug rather than a runtime concern, option 4 alone is a defensible answer — it just needs to be stated somewhere, because right now the code states the opposite.Regression test
packages/web/test/server/server-functions-flash-url-bound.spec.tsx, which runs against the built bundles like the other server-function specs:Against
f0f7531ball four go red on the size assertion — 4394, 40171, 4375 and 4410 bytes against the 4096 ceiling — with the third and fourth showing that the outcome and the committed mutation are what is being thrown away. The spec deliberately does not pin how the ladder pays for the url: it pins that a payload the encoder markstruncatedis a payload that fits, and that the error/thrown flags — the part the ladder's own comment says must never be lost — still arrive.