Skip to content

The degrade ladder bounds every field but url, so the cookie is discarded whole while claiming it fit #3249

Description

@frenzzy

Summary

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.mjs
import { encodeFlashCookie } from "./server-functions/dist/server.js";

const CEILING = 4096; // RFC 6265bis §5.6: the browser measures name=value
const pair = c => (c.includes("; ") ? c.slice(0, c.indexOf("; ")) : c);
const back = "/catalog/" + "a".repeat(4200); // the page the form wants to return to

const rows = { 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]
]) {
  const p = pair(encodeFlashCookie(url, result, []));
  const payload = 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.

$ git log --oneline -1 -S 'payload.truncated = true' -- packages/web/server-functions/src/flash.ts
ecfee20d fix(web): bound the flash cookie and validate unservable cookie shapes (#3137, #3138)
$ git log --oneline -1 -S 'while (prefix.length > 0 && !fitsCookie' -- packages/web/server-functions/src/flash.ts
ecfee20d fix(web): bound the flash cookie and validate unservable cookie shapes (#3137, #3138)
$ git log --oneline -1 -S 'COOKIE_PAIR_BUDGET' -- packages/web/server-functions/src/flash.ts
ecfee20d fix(web): bound the flash cookie and validate unservable cookie shapes (#3137, #3138)

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

  1. 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.

  2. 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.

  3. 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.

  4. 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. */
const COOKIE_CEILING = 4096;

function pairOf(setCookie: string) {
  const end = setCookie.indexOf("; ");
  return end < 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", () => {
    const url = "/_server/publish?return=" + encodeURIComponent("/catalog/" + "a".repeat(4200));
    const cookie = encodeFlashCookie(url, { published: true }, []);
    const pair = pairOf(cookie);

    expect(pair.length, `${pair.length} bytes — the browser discards the whole cookie`).toBeLessThanOrEqual(
      COOKIE_CEILING
    );
    // and having fit, it still says what happened
    expect(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 fits
    const url = "/_server/import?" + "q=" + "b".repeat(40000);
    const pair = pairOf(encodeFlashCookie(url, { rows: 12000 }, [{ big: "c".repeat(50000) }]));
    const payload = 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", () => {
    const url = "/_server/charge?receipt=" + "d".repeat(4200);
    const pair = pairOf(encodeFlashCookie(url, new Error("card declined"), [], true));

    expect(pair.length).toBeLessThanOrEqual(COOKIE_CEILING);
    const submission = 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 () => {
    let ran = 0;
    registerServerFunction("flash-url-bound-publish", async () => {
      ran++;
      return { published: true };
    });

    const back = "/catalog/" + "e".repeat(4200);
    const response = await handleServerFunctionRequest(
      new Request(
        "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 case
    expect(response.status).toBe(303);
    const flash = response.headers
      .getSetCookie()
      .find(entry => entry.startsWith(`${FLASH_COOKIE}=`))!;
    const pair = 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.

Activity

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