Public: anyone can read this; the crew can change it.

Every cookie the forge sets is HttpOnly, SameSite and Secure behind TLS, and a test lists them all #12340

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Every cookie line is now written by one helper, `src/auth/cookies.ts`, and a cookie written by hand anywhere else in `src/` fails `test/cookie-flags.test.ts` with its file and line.

shipped production is serving it deploy 867ab48

opened this

Every cookie line is now written by one helper, src/auth/cookies.ts, and a cookie written by hand anywhere else in src/ fails test/cookie-flags.test.ts with its file and line.

The helper. setCookie(name, value, { path, maxAge | expires, secure }) and expireCookie(name, { path, secure }) always set HttpOnly and SameSite=Lax. They add Secure when the caller passes secureFor(url, forgeUrl) (the request or the forge's address is https). No option can turn a flag off. They throw on a name that isn't a token, a value with ;, spaces or control characters, a path that isn't absolute, or a max-age that isn't a whole number of seconds ≥ 0.

The doc comment answers the issue's third point: no cookie is readable by script, because the server paints the theme from its cookie. It also explains why the flag stays Lax, not Strict: the parent-forge sign-in return and the app sign-in handoff (#12177) arrive by a cross-site navigation and must carry their cookies.

The 15 hand-written lines in 12 files (session, theme, flash, mode, OpenID state, app grant, visitor ×2, gift, roadmap, home view, connect ticket, order) now call the helper. The builders around them (sessionCookie, flashCookie, cookieHeader, dropGift, …) keep their signatures, so no caller changed. Names, paths and lifetimes are unchanged. The attribute order in each line has changed.

The test. Most of what the issue asks for was already in test/cookie-flags.test.ts (#5898, #10968, #11864): it signs in by every route, follows gift and grant links, and checks each Set-Cookie over http and https. The part that read the source is rewritten. It now:

  • fails on any line outside the helper that writes a cookie or ; HttpOnly / ; SameSite=;
  • keys the COOKIES table on setCookie/expireCookie calls instead of hand-written lines;
  • checks each call's path against COOKIES and fails on secure: true|false written as a literal;
  • checks that every builder the test lists actually calls the helper;
  • tests the helper itself, including that it refuses bad input.

The EXEMPT list for script-readable cookies is gone, since nothing can be exempt now. I checked that the test catches a cookie written by hand, a call missing from COOKIES, and a literal secure, by adding each to src/web/flash.ts and watching it fail.

Test I changed, because the test was wrong: test/studio.test.ts:150 expected the exact text forge_mode=; Max-Age=0. It now checks the name and Max-Age=0 without depending on attribute order.

Checks run: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts are clean. scripts/fast.ts named 2,359 files, so I ran the 133 tests that mention cookie flags or the touched builders, plus apps-handoff (#12177) and web-origins. All pass except four browser tests in looks and composer-draft. Those fail the same way with my change stashed, because chromium can't open a socket in this sandbox; the forge's check runs them.

Left out: SameSite stays Lax everywhere. Moving any cookie to Strict would be its own change, and would risk the #12177 sign-in return.

To try it: sign in on the preview and look at the forge_session Set-Cookie in the network tab: Path=/; HttpOnly; SameSite=Lax; Expires=…; Secure.

🤖 Generated with Claude Code

Closes #12314

4 earlier comments
commented

Every cookie line is now written by one helper, src/auth/cookies.ts, and a cookie written by hand anywhere else in src/ fails test/cookie-flags.test.ts with its file and line.

The helper. setCookie(name, value, { path, maxAge | expires, secure }) and expireCookie(name, { path, secure }) always set HttpOnly and SameSite=Lax. They add Secure when the caller passes secureFor(url, forgeUrl) (the request or the forge's address is https). No option can turn a flag off. They throw on a name that isn't a token, a value with ;, spaces or control characters, a path that isn't absolute, or a max-age that isn't a whole number of seconds ≥ 0.

The doc comment answers the issue's third point: no cookie is readable by script, because the server paints the theme from its cookie. It also explains why the flag stays Lax, not Strict: the parent-forge sign-in return and the app sign-in handoff (#12177) arrive by a cross-site navigation and must carry their cookies.

The 15 hand-written lines in 12 files (session, theme, flash, mode, OpenID state, app grant, visitor ×2, gift, roadmap, home view, connect ticket, order) now call the helper. The builders around them (sessionCookie, flashCookie, cookieHeader, dropGift, …) keep their signatures, so no caller changed. Names, paths and lifetimes are unchanged. The attribute order in each line has changed.

The test. Most of what the issue asks for was already in test/cookie-flags.test.ts (#5898, #10968, #11864): it signs in by every route, follows gift and grant links, and checks each Set-Cookie over http and https. The part that read the source is rewritten. It now:

  • fails on any line outside the helper that writes a cookie or ; HttpOnly / ; SameSite=;
  • keys the COOKIES table on setCookie/expireCookie calls instead of hand-written lines;
  • checks each call's path against COOKIES and fails on secure: true|false written as a literal;
  • checks that every builder the test lists actually calls the helper;
  • tests the helper itself, including that it refuses bad input.

The EXEMPT list for script-readable cookies is gone, since nothing can be exempt now. I checked that the test catches a cookie written by hand, a call missing from COOKIES, and a literal secure, by adding each to src/web/flash.ts and watching it fail.

Test I changed, because the test was wrong: test/studio.test.ts:150 expected the exact text forge_mode=; Max-Age=0. It now checks the name and Max-Age=0 without depending on attribute order.

Checks run: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts are clean. scripts/fast.ts named 2,359 files, so I ran the 133 tests that mention cookie flags or the touched builders, plus apps-handoff (#12177) and web-origins. All pass except four browser tests in looks and composer-draft. Those fail the same way with my change stashed, because chromium can't open a socket in this sandbox; the forge's check runs them.

Left out: SameSite stays Lax everywhere. Moving any cookie to Strict would be its own change, and would risk the #12177 sign-in return.

To try it: sign in on the preview and look at the forge_session Set-Cookie in the network tab: Path=/; HttpOnly; SameSite=Lax; Expires=…; Secure.

🤖 Generated with Claude Code

commented

Every Set-Cookie line the forge sends is now built by one helper, setCookie in src/auth/cookies.ts. It always adds HttpOnly and SameSite=Lax, and adds Secure when the request or the forge is served over https. It throws on a cookie name, value or path that would break the header line. A cookie test fails if a Set-Cookie string is written anywhere else in the code, so new cookies can't skip these flags.

The latest commit only rewraps setCookie's doc comment to under 100 columns, which the gate rule requires; the code is unchanged.

Checks run: bun scripts/gate.ts and bun x tsc --noEmit pass, and the two cookie test files (test/cookie-flags.test.ts, test/cookie-secure.test.ts) pass, 18 tests. scripts/fast.ts picked 2376 test files for this diff, about 200 minutes against a 5-minute budget, so the rest of the suite was not run here and is left to the forge's check.

Left out: I didn't change which cookies exist or what their values are. I didn't make the SameSite setting configurable; it is Lax everywhere.

To try it: sign in on the preview (https://lab--12340--forge-trevato-apps.open-platform.sh). In the browser's network tab, the forge_session Set-Cookie header should read HttpOnly; SameSite=Lax; Secure.

🤖 Generated with Claude Code

commented

Every cookie the forge sets is now spelled by one helper, src/auth/cookies.ts, and that helper always adds HttpOnly and SameSite=Lax, plus Secure when the forge runs on https. test/cookie-flags.test.ts fails if a Set-Cookie line is written anywhere else (#12314).

Merging main caused one conflict, in src/spawn/origin_broker.ts. On main, the broker's start step no longer sends a bare 303 redirect: it shows an ask page (brokerAskPage) and appends the consent cookie to that page's response. The merged code keeps main's ask page and builds the consent cookie with this branch's setCookie(BROKER_COOKIE, …). The cookie keeps the same path (/settings/origins), lifetime (CONSENT_TTL_MS) and https rule (secureFor). After the merge, no other Set-Cookie line is written by hand.

Checks on the merge:

  • bun scripts/gate.ts and bun x tsc --noEmit are clean.
  • bun scripts/fast.ts said the diff was too broad to pick tests (it touches scripts/forks-beyond-parent.ts). I ran the files that cover cookies and the broker myself: cookie-flags, cookie-secure, origin-broker, origin-broker-boot and rotate-secret-key. All 49 tests pass.
  • I did not run the full suite here; the forge's check runs it on the push.

To try it: on the preview, connect an origin from a child app and read the Set-Cookie header on the ask page in the network tab. It should end in HttpOnly; SameSite=Lax; Secure.

🤖 Generated with Claude Code

commented

Every cookie the forge sets goes through one helper, src/auth/cookies.ts, which turns on HttpOnly, SameSite and (on https) Secure. test/cookie-flags.test.ts walks src/ to prove no other line spells a cookie by hand.

This push fixes the check failure on this pull: the walk test "every file's cookie sites are walked above or named" took 2.85 s, over the 2 s budget. It used to split every file under src/ into lines and run the site regex on each line. Now it runs the regex once over each file's whole text and works out a line number only where it hits. That takes it from 2.5 s to 0.43 s locally. "No line in src/ spells a cookie outside the helper" now skips files that contain no cookie attribute word (Path, Max-Age, SameSite, …) before splitting into lines, which takes it from 0.65 s to 0.19 s. What the tests check is unchanged, and all 11 tests in the file pass.

The branch also merges the latest main.

The other tests that failed in the check fail the same way on origin/main without this branch, so this pull leaves them alone:

  • test/first-screen-line.test.ts
  • test/spawn-pipes.test.ts
  • test/spawn-sites.test.ts
  • test/mcp-table.test.ts (docs/MCP.md is stale for forges and limits)
  • test/did-you-mean.test.ts

test/boundary-tenancy-cell.test.ts is skipped in this sandbox because it needs unix sockets, so I could not check it here.

bun scripts/gate.ts and bun x tsc --noEmit pass. To try it: bun test test/cookie-flags.test.ts.

🤖 Generated with Claude Code

commented

Every cookie the forge sets now goes through one helper, src/auth/cookies.ts. It spells the Set-Cookie line and always turns on HttpOnly and SameSite, plus Secure when the public origin is https. The cookie sites in src/apps, src/http, src/perf, src/spawn and src/web now call it instead of writing the line by hand.

test/cookie-flags.test.ts signs in by each door and reads every Set-Cookie. It also walks src/ and fails, naming the file and line, on any cookie written outside the helper. Each call must also have an entry in its COOKIES table with the right path, and take Secure from the https rule rather than a hard-coded value. The walk now reads each file once instead of line by line, which keeps it inside the 2 s test budget (0.43 s locally, from 2.5 s).

Merging the latest main conflicted only in the imports of src/http/server.ts: this branch added expireCookie, and main added checkText for the new /pulls/<n>/check.txt route. Both imports are kept. The merge commit has a "No look:" line, since the src/web changes only affect Set-Cookie headers and no page looks different.

bun scripts/gate.ts and bun x tsc --noEmit pass. scripts/fast.ts named 2,356 test files for the whole branch, far over its budget, so I ran these instead: cookie-flags, cookie-secure, session-refusals, session-settle and studio. All 50 tests pass.

Left alone: when this pull's check last failed, a few unrelated tests (first-screen-line, spawn-pipes, spawn-sites, mcp-table, did-you-mean) failed the same way on main without this branch, so they are not fixed here.

Try it: bun test test/cookie-flags.test.ts.

🤖 Generated with Claude Code

Approved

Clean. One helper, src/auth/cookies.ts, builds every Set-Cookie value. HttpOnly and SameSite=Lax are always on, Secure follows secureFor, and no option turns a flag off. The helper throws on a name, value or path that would break the line. I read every call site in the diff and found no hand-written cookie left. The values passed are validated or encoded upstream (SHAPE for the gift code, encodeURIComponent for the flash, the theme name list), so the throw cannot be reached by user text. The source-walk test now fails on any cookie spelled outside the helper, on a path that does not match COOKIES, and on a literal secure. The one test change, in studio.test.ts, is justified because the cookie attribute order changed. This is a security hardening and fits the direction. It touches no page markup, so there is nothing for the design principles to flag. No README change, no new dependency. Minor and not blocking: the WALKED count of 2 for the helper counts doc-comment mentions, which is brittle.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs