Skip to content

The flash cookie carries the submission with no SameSite, no lifetime and no __Host- prefix #3239

Description

@frenzzy

Summary

The cookie that carries a no-JS server-function outcome back to the browser is written with HttpOnly and Secure and nothing else: no SameSite, no Max-Age/Expires, and no __Host- prefix. What rides it is not a status line, it is the submission — a no-JS login form flashes input verbatim, so the user's password sits in the cookie value as plaintext JSON (the codec is deliberate about the payload being plain JSON, flash.ts:17). With no lifetime that is a session cookie at Path=/: any submission whose outcome is never read is re-sent on every subsequent request to the origin — scripts, images, fonts — and lands in access and CDN logs, for as long as the browser lives. With no __Host- prefix a sibling subdomain can set a Domain-scoped flash of its own; the Cookie header is one string, parseCookieHeader is last-wins, and the render shows the attacker's "result" as the outcome of the user's submission.

This issue merges four symptoms that would otherwise be filed separately, because the fix is one hunk region and the pieces cannot be moved independently (see Options): flash cookie has no SameSite; flash cookie never expires / the submission rides every request; flash cookie is not __Host- / a sibling subdomain can shadow it; clearFlashCookie's attribute set disagrees with the write, and a __Host- deletion cookie without Secure is silently rejected exactly like the cookie it meant to delete (the #3138 shape).

Affects @solidjs/web@2.0.0-rc.6.

Reproduction

The two files are copied out of the repo at f0f7531b, unmodified except for one import specifier so node can run them with type stripping (the in-repo dist/ is already patched, so it cannot be used to show the released behaviour):

git clone https://github.com/solidjs/solid && cd solid && git checkout f0f7531b
mkdir -p /tmp/r/packages/web/src /tmp/r/packages/web/server-functions/src
cp packages/web/src/cookies.ts /tmp/r/packages/web/src/
cp packages/web/server-functions/src/flash.ts /tmp/r/packages/web/server-functions/src/
sed -i'' -e 's#"../../src/cookies.js"#"../../src/cookies.ts"#' \
  /tmp/r/packages/web/server-functions/src/flash.ts
// /tmp/r/repro.mjs — node --experimental-strip-types repro.mjs
import { encodeFlashCookie, decodeFlashCookie } from "./packages/web/server-functions/src/flash.ts";
import { FLASH_COOKIE, clearFlashCookie, serializeCookie } from "./packages/web/src/cookies.ts";

const attrs = s => s.split(";").slice(1).map(p => p.trim());
const has = (s, n) =>
  attrs(s).some(p => p.toLowerCase() === n || p.toLowerCase().startsWith(n + "="));

// A no-JS login form post, exactly as the handler hands it to the encoder.
const form = new FormData();
form.set("email", "ada@example.com");
form.set("password", "correct-horse-battery-staple");
const set = encodeFlashCookie("/_server/log-in", { welcome: "Ada" }, [form]);
const clear = clearFlashCookie();

// CONTROL: the same package's public cookie helper, asked for a bounded
// host-locked cookie. Same file, same release.
const control = serializeCookie("__Host-session", "abc", {
  secure: true, httpOnly: true, sameSite: "lax", maxAge: 60
});

const row = (label, s) =>
  [label.padEnd(22),
   String(has(s, "samesite")).padEnd(9),
   String(has(s, "max-age") || has(s, "expires")).padEnd(9),
   String(s.split("=")[0].toLowerCase().startsWith("__host-")).padEnd(7),
   String(has(s, "secure")).padEnd(7),
   String(has(s, "httponly"))].join(" ");

console.log("name                            =", FLASH_COOKIE);
console.log("");
console.log("                       SameSite  lifetime  __Host- Secure  HttpOnly");
console.log(row("flash write", set));
console.log(row("flash clear", clear));
console.log(row("CONTROL __Host-session", control));
console.log("");
console.log("flash write   :", set.slice(0, 120) + " ...");
console.log("flash clear   :", clear);
console.log("CONTROL       :", control);
console.log("");
console.log("the submission is in the value the browser keeps re-sending:",
  decodeURIComponent(set).includes("correct-horse-battery-staple"));

// A sibling subdomain sets `flash=...; Domain=app.example; Path=/`; the app
// receives both entries in one Cookie header.
const entry = r => `${FLASH_COOKIE}=${encodeURIComponent(JSON.stringify({ url: "/x", result: r }))}`;
console.log("two entries, one Cookie header -> render sees:",
  JSON.stringify(decodeFlashCookie(`${entry("ours")}; ${entry("theirs")}`).result));

// What the package itself says about the clear's attribute set, once the
// name carries the prefix (DEV build; the check is cookies.ts's own).
try {
  serializeCookie("__Host-flash", "", { maxAge: 0, path: "/" });
  console.log("dev check on the clear's attributes: accepted");
} catch (e) {
  console.log("dev check on the clear's attributes:", e.message);
}

Measured output, node v24.19.0:

name                            = flash

                       SameSite  lifetime  __Host- Secure  HttpOnly
flash write            false     false     false   true    true
flash clear            false     true      false   false   false
CONTROL __Host-session true      true      true    true    true

flash write   : flash=%7B%22url%22%3A%22%2F_server%2Flog-in%22%2C%22result%22%3A%7B%22welcome%22%3A%22Ada%22%7D%2C%22error%22%3Afalse%2C ...
flash clear   : flash=; Max-Age=0; Path=/
CONTROL       : __Host-session=abc; Path=/; Max-Age=60; HttpOnly; Secure; SameSite=Lax

the submission is in the value the browser keeps re-sending: true
two entries, one Cookie header -> render sees: "theirs"
dev check on the clear's attributes: serializeCookie: every browser silently rejects this cookie — the __Host- prefix on `__Host-flash` requires `secure: true`. It would never come back on a request, with no error anywhere.

Three things to read out of that table.

The CONTROL row is the same package, the same file, the same release: serializeCookie produces a host-locked, SameSited, bounded cookie the moment a caller asks for one. The gap is not a missing capability, it is that the framework's own cookie does not use it.

The write and clear rows already disagree — HttpOnly and Secure on one, not on the other. That drift is harmless today, because cookie deletion matches on name/path/domain and ignores Secure/HttpOnly. It stops being harmless the moment the name carries a prefix, which is what the last line shows: assertServableCookie, which lives 80 lines above clearFlashCookie in the same file, already refuses exactly the attribute set clearFlashCookie hand-rolls.

The shadowing line is the one that cannot be fixed at the read. Last-wins is the RFC's own reading, and the parser sees a single Cookie header with no way to tell a sibling's entry from the app's own.

Where

Released tree, f0f7531b (@solidjs/web@2.0.0-rc.6):

  • packages/web/server-functions/src/flash.ts:130-131 — the write. serializeCookie(FLASH_COOKIE, JSON.stringify(payload), { secure: true, httpOnly: true }): the whole attribute set.
  • packages/web/server-functions/src/flash.ts:20 — the import that ties the codec to serializeCookie rather than to a shared writer.
  • packages/web/src/cookies.ts:168export const FLASH_COOKIE = "flash";
  • packages/web/src/cookies.ts:184-185clearFlashCookie(), the second, independently spelled attribute set, in a different package directory (src/ vs server-functions/src/, different bundle entries).
  • packages/web/server-functions/src/server.ts:1771-1776 — the caller: headers.append("Set-Cookie", encodeFlashCookie(url.pathname + url.search, result, args, thrown)) on the 302 back to the form page.

Provenance. The attribute set has been { secure: true, httpOnly: true } and the name has been "flash" since the protocol entered the tree:

$ git log --oneline -1 -S 'FLASH_COOKIE = "flash"' -- packages/web/src/cookies.ts
71821959 Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.
$ git log --oneline -1 -S 'serializeCookie(FLASH_COOKIE' -- packages/web/server-functions/src/flash.ts
71821959 Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.

71821959 (2026-08-25) only renamed cookies.jscookies.ts; the code came in with 89a0531c (2026-08-25, "Absorb expressions into Solid and collapse the rxcore seam"), as packages/web/src/cookies.js:101,118 and packages/web/src/server-functions/flash.js:60 — byte-identical attributes and name — which in turn absorbed the flash-cookie protocol vendored from @dom-expressions/runtime in 2a38f8aa (2026-07-28). So this is not a regression; it is the shape the protocol has always had.

A neighbouring fix is relevant, though. ecfee20d (2026-08-31, "fix(web): bound the flash cookie and validate unservable cookie shapes (#3137, #3138)") touched both of these files. It added assertServableCookie — the dev-time check that encodes precisely the __Host- rules broken here, including the "browsers reject this on arrival with no trace" reasoning — and it rewrote the flash encoder around it (flashCookie(), the size ladder), while leaving the flash cookie's own name and attributes exactly as they were. The knowledge and the defect were introduced within the same commit, one file apart.

Why it matters

Three paths, with honestly different reachability.

The lifetime is unconditional. No unusual integration and no attacker is needed. Clearing the cookie is delegated to the integration (clearFlashCookie, consumed eagerly per request from a router's isomorphic core) and no integration is required to exist — an app that reads the flash by hand, or reads it only on the page it expects the redirect to land on, keeps it. Until something clears it, the submission is a session cookie at Path=/: attached to every request to the origin including static assets, and written into every access log and CDN log along the way. For a no-JS login form that is the password, in the clear, in the log pipeline. This is the piece I would fix even if the other two were declined.

The __Host- prefix needs a hostile sibling subdomain under the same registrable domain — user-content subdomains, a dangling CNAME, a compromised marketing host. Given one, evil.app.example sets flash=<payload>; Domain=app.example; Path=/, the app receives two entries, and the repro above shows what the render gets: "theirs". The attacker chooses url, result and the error/thrown flags, so they choose the outcome an integration renders for a submission the user actually made — "saved" for a mutation that failed, or an error for one that committed. clearFlashCookie carries no Domain, so the tossed cookie is never cleared either and it persists across the pages that follow. Prerequisite is real; without a sibling you control this path is closed.

SameSite needs the origin gate widened. handleServerFunctionRequest is same-origin by default and rejects a POST whose Sec-Fetch-Site/Origin/Referer says cross-site, so on the stock configuration a cross-site form post never reaches the encoder. The path opens for an app that sets a custom origin matcher (multi-tenant, a partner origin) or allowRequestsWithoutOriginCheck: true; then a cross-site post's outcome is stored in the user's jar and rendered on their next visit. SameSite=Lax closes it at the browser instead of relying on the gate's configuration — a cookie with SameSite=Lax is not stored from a cross-site top-level POST at all. Independently of that gate, a cookie that names no SameSite inherits whatever the browser's default is, which differs across browsers and changes over time; a cookie carrying the submission should not be depending on that.

What is not claimed: the value is HttpOnly, so page script (including an XSS on the app) cannot read the submission out of it, and the sibling-subdomain path is a spoof of the outcome, not a read of the victim's cookie. The severity is in the plaintext submission's blast radius over time, and in an outcome the app did not produce being rendered as one it did.

Options

A. Document it; change nothing. Zero wire change and zero risk. But the app never spells this cookie — the framework does — so "set SameSite yourself" is not advice an app can act on. Rejected on that ground alone, though it stays the honest option if the rename is judged too expensive for a patch.

B. Add SameSite=Lax and a Max-Age, keep the name flash. Closes the unconditional lifetime problem and the cross-site-store problem with no wire-visible change at all — nothing that reads document.cookie or filters flash at a proxy breaks. Leaves the sibling-shadowing open, and leaves the two attribute sets spelled twice, free to drift again. Cheapest thing that helps, and a legitimate stopping point if the rename is unwanted.

C. Make the attributes an option (flashCookie: { name, sameSite, maxAge } on the handler). Every value other than the one below is worse, and the knob invites an app to hand itself the bug back. Against Solid's minimalism: this is the framework's own transport detail, not application policy.

D. Route the write through serializeCookie with full options and leave the clear hand-rolled. Buys the dev-time assertServableCookie check for the write. Recreates the exact split this issue is about: the clear is deliberately hand-rolled so an integration that only clears the cookie does not drag the pair codec into its client bundle (cookies.ts:14-18, guarded by scripts/size-guard.mjs), so serializeCookie cannot own both sides.

E (recommended). One writer, shared by the set and the clear, and the prefix in the name. FLASH_COOKIE = "__Host-flash"; a single FLASH_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax" and FLASH_MAX_AGE; writeFlashCookie(value) exported from cookies.ts; clearFlashCookie() spelled from the same two constants with Max-Age=0; flash.ts calls writeFlashCookie instead of serializeCookie. All four edges close at once, the two attribute sets become physically the same string, and it stays a string concat — no pair codec in the isomorphic clear path, no new option, no new export beyond the one writer. In Solid's terms this is the smaller surface, not the larger one: it removes a second hand-rolled attribute set rather than adding a knob.

Why the pieces cannot be split: the prefix rules bind set and clear together. A __Host- deletion cookie without Secure is rejected on arrival with no trace, exactly like the cookie it meant to delete — the last line of the repro is serializeCookie's own dev check saying so — so a rename that does not also fix the clear leaves an outcome that can never be cleared. Naming, attributes and lifetime have to land as one hunk.

Three judgement calls that are the maintainer's, not mine:

  • The rename is wire-visible. FLASH_COOKIE is exported, so in-tree and typed consumers follow it, but an integration that hardcoded the literal "flash" — reading document.cookie, an edge worker, a proxy that strips or forwards the cookie by name — breaks silently on upgrade. Whether that is a patch, a note in the changeset, or a minor is your call; the changeset in my branch states the break explicitly rather than burying it.
  • Lax vs Strict. Both legs of this protocol are same-site (the 302 back to the form page and the render that follows), so Strict would also work and would additionally keep the cookie off cross-site-initiated top-level GETs. Lax is the safer default for apps that bounce through an external hop (a payment page) between the mutation and the read. I chose Lax; Strict is defensible.
  • The lifetime's value, and whose it is. I used Max-Age=60 — one redirect's worth. It could equally be argued that the lifetime belongs to the adapter, since the adapter knows whether an integration clears the cookie eagerly. A runtime default of 60 seconds does not stop an adapter from clearing it sooner, which is why I put it in the runtime.

Regression test

packages/web/test/server/server-functions-flash-cookie-attributes.spec.tsx, four assertions, one per symptom:

/**
 * The attributes the flash cookie is written with — the half of the no-JS
 * leg the browser enforces, and the half nothing in this package can
 * observe once it is wrong.
 *
 * What rides this cookie is not a status line: it is the SUBMISSION. A
 * no-JS login form flashes `input` verbatim, so the user's password sits in
 * the value as plaintext JSON. The codec is deliberate about that being
 * plaintext (flash.ts: "The payload is plain JSON rather than the wire
 * codec") and confidentiality is the caller's, but plaintext raises the bar
 * on the three attributes that decide WHO the browser hands it back to and
 * FOR HOW LONG, and today the encoder sets none of them:
 *
 *   - No `SameSite`, so a cross-site form post's outcome is stored and
 *     replayed by the ordinary defaults an app never sees.
 *   - No `Max-Age` and no `Expires`, so it is a SESSION cookie at `Path=/`:
 *     it is attached to every subsequent request to the origin — scripts,
 *     images, fonts — and lands in access and CDN logs, for as long as the
 *     browser lives. Clearing it is delegated to the integration
 *     (`clearFlashCookie`, cookies.ts), and no integration is required to
 *     exist; an app that reads the cookie by hand keeps it forever.
 *   - No `__Host-` prefix, though this codebase knows the prefix well
 *     enough to refuse cookies that break it (cookies.ts
 *     `assertServableCookie`). Without host-locking, a sibling subdomain
 *     can toss a `Domain`-scoped `flash` at the app; `parseCookieHeader` is
 *     last-wins, so the tossed one can displace the real outcome and the
 *     app renders an attacker's "result" as its own. `clearFlashCookie`
 *     carries no `Domain`, so a tossed cookie is never cleared either — the
 *     prefix closes both, which is why it is the fix rather than a
 *     domain-guessing clear.
 *
 * The prefix is not free: `__Host-` requires `Secure`, so the flash never
 * reaches a plain-http origin other than localhost. The encoder already
 * hardcodes `secure: true`, so that is the status quo, not a regression.
 *
 * Like the other server-function specs, these run against the built bundles
 * (server-functions/dist/*, wired up in vite.config.server.mjs).
 */
import { describe, expect, it } from "vitest";
import {
  FLASH_COOKIE,
  clearFlashCookie,
  decodeFlashCookie,
  encodeFlashCookie
} from "@solidjs/web/server-functions/server";

/** The attribute list of a Set-Cookie value, lowercased for matching. */
function attributesOf(setCookie: string) {
  return setCookie
    .split(";")
    .slice(1)
    .map(part => part.trim().toLowerCase());
}

function hasAttribute(setCookie: string, name: string) {
  return attributesOf(setCookie).some(part => part === name || part.startsWith(`${name}=`));
}

/** A no-JS login post, as the encoder receives it. */
function loginOutcome() {
  const form = new FormData();
  form.set("email", "ada@example.com");
  form.set("password", "correct-horse-battery-staple");
  return encodeFlashCookie("/_server/log-in", { welcome: "Ada" }, [form]);
}

describe("the flash cookie is written so the browser can bound it", () => {
  it("names a SameSite, so a cross-site post's outcome is not stored unasked", () => {
    const cookie = loginOutcome();
    expect(attributesOf(cookie)).toContain("httponly"); // the one it does set
    expect(hasAttribute(cookie, "samesite")).toBe(true);
  });

  it("carries a lifetime, so an unread outcome does not ride the whole session", () => {
    // the value the browser would keep sending: it is the submission, in
    // the clear, on every request to the origin including assets
    const cookie = loginOutcome();
    expect(decodeURIComponent(cookie)).toContain("correct-horse-battery-staple");
    expect(hasAttribute(cookie, "max-age") || hasAttribute(cookie, "expires")).toBe(true);
  });

  it("is host-locked, so a sibling subdomain cannot toss one at the app", () => {
    // evil.app.example sets `flash=...; Domain=app.example; Path=/` and the
    // app receives TWO entries of the same name. Last-wins is not the bug —
    // it is the RFC's own reading and cannot be legislated away in the
    // parser, which sees one header and cannot tell the entries apart:
    const shadowed = decodeFlashCookie(
      `${FLASH_COOKIE}=${encodeURIComponent(JSON.stringify({ url: "/x", result: "ours" }))}; ` +
        `${FLASH_COOKIE}=${encodeURIComponent(JSON.stringify({ url: "/x", result: "theirs" }))}`
    );
    expect(shadowed?.result).toBe("theirs");

    // so the collision has to be made impossible upstream, and the platform
    // already has the mechanism: `__Host-` forbids `Domain`, which locks a
    // sibling's cookie to the sibling and leaves exactly one entry here.
    // The same prefix is what makes `clearFlashCookie`'s Domain-less clear
    // complete rather than partial.
    expect(FLASH_COOKIE.toLowerCase().startsWith("__host-")).toBe(true);
  });

  it("is cleared by a cookie the browser will actually accept for that name", () => {
    // the prefix rules apply to the DELETION Set-Cookie too: a `__Host-`
    // name without `Secure` is rejected on arrival with no trace (#3138),
    // so the clear silently fails and the outcome haunts the next request
    const clear = clearFlashCookie();
    expect(clear.startsWith(`${FLASH_COOKIE}=`)).toBe(true);
    expect(hasAttribute(clear, "path")).toBe(true);
    expect(hasAttribute(clear, "max-age")).toBe(true);
    expect(hasAttribute(clear, "secure")).toBe(true);
  });
});

All four go red against the released codec — not one of them, all four, which is the argument for treating this as one issue. Running the same four assertions directly against the f0f7531b sources from the repro directory above:

  FAIL  names a SameSite
  FAIL  carries a lifetime
  FAIL  is host-locked
  FAIL  clear is a cookie the browser accepts for that name

And against the fix (npx vitest run --config vite.config.server.mjs test/server/server-functions-flash-cookie-attributes.spec.tsx, from packages/web):

 Test Files  1 passed (1)
      Tests  4 passed (4)

The third assertion is the one that is not merely an attribute check: it first proves the shadowing is real at the decode ("theirs" wins) and only then asserts the prefix, so it stays red for any fix that tries to defend at the read instead of at the name. Post-fix the write is __Host-flash=…; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=60 and the clear is the same string with Max-Age=0.

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