Skip to content

Scope each publisher's cookies to its own path - #1400

Open
jwrosewell wants to merge 54 commits into
feature/49-host-featuresfrom
feature/50-cookies-by-path
Open

jwrosewell wants to merge 54 commits into
feature/49-host-featuresfrom
feature/50-cookies-by-path

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 10 files, 776 lines added, 43 removed
docs/superpowers 1 file, 138 lines added, 0 removed
crates/trusted-server-adapter-fastly/src 1 file, 30 lines added, 2 removed
CHANGELOG.md 1 file, 1 line added, 0 removed

Closes

Follows #1328, whose design records the browser limit this pull request lifts.

Spec

Spec: cookies-by-path

Test plan

CI on this pull request at cdb0a2b passed all 27 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at cdb0a2b

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

On top of the Fastly names series (#1399). Follows #1328, whose design records the limit this lifts.

What this adds

What changes.

On a host that serves several publishers by path, every cookie core writes now carries the removed path prefix as its Path, so the browser keeps each publisher's cookies under that publisher's path. A request with no removed prefix writes Path=/ exactly as before. No setting is added.

How it works.

  • The prefix carries the path. PublicPrefix gains under_path, for_request and cookie_path. Every core cookie writer already takes the publisher's PublicPrefix, so each writes the name and the Path from one value. That covers the Edge Cookie and its expiry, the resolved marker and its expiry, the pull-sync marker and the tester cookie.
  • Cookies go under / where the removed prefix cannot be a cookie path. RequestAddress::new accepts any removed prefix, as before this series, and PublicPrefix::under_path writes a prefix that cannot be a cookie's Path, such as one with a ;, as / with a warning, so no Set-Cookie core writes carries it.
  • The first of two same-named cookies wins where core writes under the prefix. RFC 6265 section 5.4 asks a browser to list the cookie with the longer path first. The cookie crate's jar keeps the last of two same-named cookies, so where core writes its cookies under the removed prefix its jar now keeps the first of each name. An empty prefix, / and a prefix that cannot be a cookie path keep the last, as a request with no prefix does.
  • Consent cookies and sharedId. The permission signal crates read consent cookies through RequestInfo::cookie, which already returns the first match, and core only checks whether sharedId is present. A test holds the first-match behavior in place.

Why

Request addressing lets one host serve publishers as https://front.example/<publisher>/<path>, and its design records that publishers on one host are one browser origin, sharing consent cookies, the sharedId cookie and the forwarded Cookie header. Browsers already keep cookies apart by path, so core uses that rule instead of a setting.

What is left

  • The client script still calls root-relative endpoints (/auction, the identity resolve endpoint, page bids), and some page integrations write cookies with Path=/ from the page. The design describes the fix, which is for the server to tell the script the removed prefix in the page.
  • Cookies under / from origins, from consent tools not set up for the publisher's path, or from core before this change stay shared and reach every origin.
  • Core does not write a root Edge Cookie or resolved marker left from before the change again under the publisher's path, so either stays under / until its Max-Age ends, and on that publisher's path the newer path-scoped cookie wins. A withdrawal expires both under / as well as under the prefix.

Tests

The design page lists the tests this series adds, under "Tests" in its section 2.

Review

The design's first review round found five blocking issues, recorded in its review log. The three that needed code changes are fixed in this series, a removed prefix that cannot be a cookie path addressing the request as before (a0d1d00), an empty or root prefix reading the cookie jar as no prefix does (25c673f), and tests reaching the production wiring through the Edge Cookie context and the resolve endpoint (630068d). The second round found no blocking issue.

The design is docs/superpowers/specs/2026-10-15-cookies-by-path-design.md, also in #1342 with every design document of the stack.

…efix and returning visitors

Review round 1 found five blocking issues. The design said the adapters
answer 421 for a removed prefix that is not a cookie path, when
RequestAddress::new returns the error to the hook and only the Fastly
adapter answers 421, for a missing blob. It said the first response
writes each core cookie under the publisher's path, when a returning
visitor's response writes no Edge Cookie, so the root cookie stays for up
to a year and a withdrawal does not remove it. It said a request with no
prefix reads as today, when an empty removed prefix writes Path=/ yet
reads the first of two cookies of one name. Its adapter table, "so none
changes", tests and acceptance did not cover the Fastly tester routes,
and no test reaches the code that builds the prefix in production.

The design now states what the code does in each case, says that only the
Fastly adapter serves the tester routes, and adds the consent tools that
write Path=/, signed origins, module cookies, nested prefixes and the
browser ordering the reads rely on. The code changes the round asks for
wait under Open questions, and this commit changes no code. The review
log and the revision record carry the round, and the spec passes
Prettier.
Review round 2 found no blocking issues and asked for three corrections.
The design said no test sends a request with a removed prefix through
the readers of the Edge Cookie context, when one test in publisher.rs
does, and the Fastly tester test does too, neither checking a cookie the
context wrote. It named five integrations of the client script that use
root-relative paths or write cookies from the page, and the script has
twelve, so the design now names them all. The list of untrue statements
under Open questions now also holds the claim in the request addressing
spec that this design keeps the consent cookies, sharedId and the
forwarded Cookie header with the publisher, the rustdoc of for_request
saying that publishers never receive each other's cookies, and the doc of
the cookie jar that states the longer-path-first order as a fact where
RFC 6265 makes it a SHOULD.

No code changed and the request addressing spec is untouched. The review
log and the revision record carry the round, and the spec passes
Prettier.
…efore

RequestAddress::new no longer refuses a removed prefix that cannot be a
cookie's Path. The refusal made the documented hook answer "by host" for
a request such as /a;b/news, so on Fastly that request was served from
the front host's own blob where it had been answered 421. The request
is now addressed with the selector value a;b again, and
PublicPrefix::under_path, which falls back to / with a warning, still
keeps a `;` out of every Set-Cookie core writes.

The InvalidPath error and the docs of new name the continuing path
only, the doc of is_continuing_path is back above that function, and
the module and RemovedPathPrefix docs say core writes the prefix as the
Path of its own cookies.
handle_request_cookies kept the first cookie of each name whenever a
request carried a RemovedPathPrefix, and core inserts one even when the
hook removed nothing. A request whose cookies core writes under / then
read the first of two cookies of one name, where a request with no
prefix reads the last.

The jar now keeps the first cookie only where core writes its cookies
under the removed prefix. addressing::cookie_path_for holds that one
rule, so an empty prefix, / and a prefix that cannot be a cookie's Path
all read the last cookie and write under /, and under_path uses the
same rule, so a prefix under / equals one with no path.

The docs of handle_request_cookies and of the first-wins parser now say
that RFC 6265 section 5.4 makes the longer-path-first order a SHOULD,
and what happens in a browser that does not sort.
…ext and the resolve endpoint

Two pieces of production code build the prefix the cookie writers use,
being the for_request call in EcContext::read_from_request_with_geo_status
and the resolve endpoint's use of the context's prefix, and no test
failed when either was removed.

finalize_writes_the_edge_cookie_under_the_removed_prefix reads the
context from an addressed request, generates an identifier and runs
ec_finalize_response. resolve_writes_under_the_removed_prefix_of_an_addressed_request
reads the context from the resolve request itself, as an adapter does,
and checks the Path of the Edge Cookie and its marker.

Each test was run once with its piece of code reverted. With the context
built from services.public_prefix().clone() the finalize test failed
with the Edge Cookie at Path=/, and with the resolve endpoint writing
under services.public_prefix() the resolve test failed with both cookies
at Path=/.
On a request addressed by path a withdrawal expired the Edge Cookie and
its resolved marker only under the removed prefix, so a copy of either
under /, such as one written before core wrote under the publisher's
path, stayed in the browser and was still sent on every path of the
host.

expire_ec_cookie, which core calls only on a withdrawal, now also
expires both cookies under / when the prefix carries a path. With no
removed prefix it writes the two expiries under / exactly as before. A
cookie under / is one cookie for every publisher on the host that uses
its name, so the root expiry reaches all of them.

an_expiry_under_a_removed_prefix_also_expires_the_root_cookies checks
the four Set-Cookie values byte for byte, and
a_withdrawal_under_a_removed_prefix_also_expires_the_root_cookies runs
a withdrawal through EcContext::read_from_request and
ec_finalize_response, with and without a removed prefix.
The docs of PublicPrefix and EcContext::public_prefix described only the
names a prefix gives, and the rustdoc of PublicPrefix::for_request said
publishers served from one host by path never receive each other's
cookies, which holds only for cookies written under a publisher's path.
They now say a prefix carries the Path of the cookies it names, when
that path is /, and that a cookie under / is still sent on every
publisher's requests.
… to 4

Open questions 1 to 4 are now design text. RequestAddress::new accepts
any removed prefix and under_path holds the one check, so /a;b/news is
addressed as before and answered 421 on Fastly. A removed prefix that is
empty, / or not a cookie path writes and reads its cookies as no prefix
does, through cookie_path_for. A withdrawal also expires the Edge Cookie
and the resolved marker under /, and the text says that cookie is shared
by every publisher on the host that uses its name.

Section 2 lists the new tests and the revert check of the two tests
through the production wiring, section 3 and the acceptance follow the
new behavior, and the revision record has a row for the code fixes. The
code comments open question 5 listed are corrected with the code, so the
one open question left is the pages this change makes untrue.
The series had no changelog entry. The new entry, beside the request
addressing entry under Added, says which cookies core writes under the
removed prefix, when a request still writes Path=/, which cookie of a
name is read, what a withdrawal expires, and which cookies stay shared.
@jwrosewell
jwrosewell removed this pull request from stack #1401 October 15, 2026 20:51
…text

A returning visitor whose only Edge Cookie was written under / before
core wrote under the publisher's path withdraws on a request addressed
by path. a_withdrawal_reaches_a_returning_visitors_root_edge_cookie
reads the context through EcContext::read_from_request with the
built-in module selected, runs ec_finalize_response, and checks that
the Edge Cookie and its resolved marker are expired under the prefix
and under /, and that the visitor's row is tombstoned.

The test passes on the code as it was, because expire_ec_cookie already
expires both cookies under / when the prefix carries a path. With that
root expiry removed it fails with only the two expiries under the
prefix, so it keeps that fix in place.
… under the root

On a request addressed by path a withdrawal expired the pull-sync
marker only under the removed prefix. A marker under /, written before
core wrote under the publisher's path or by a request addressed by
host, is sent on that request too, and it stayed in the browser for up
to an hour after the withdrawal. The marker is an HMAC tag over the
Edge Cookie identifier, and docs/guide/edge-cookies.md says a
withdrawal expires any marker the request carries.

ec_finalize_response now also expires the marker under / when the
prefix carries a path, as expire_ec_cookie does for the Edge Cookie.
With no removed prefix it writes the one expiry under / as before.

a_withdrawal_under_a_removed_prefix_also_expires_a_root_pull_sync_marker
sends a marker on an addressed request and checks the paths of the
marker's expiries. Without the fix it fails with the expiry under
/publisher-a.example alone.
…ookie the request carried

On a request addressed by path, a browser that holds an Edge Cookie
under the publisher's path and one of another value under / sends
both, for example when a request addressed by host created the second.
The jar keeps one of them, so a withdrawal tombstoned only that
identifier's row. The other row stayed live with consent granted,
although the withdrawal expired its cookie in the browser.

parse_ec_from_request now keeps the values of the other Edge Cookies
when the prefix carries a path, read with the new
cookies::cookie_values, and withdrawal_kv_keys adds their rows to the
ones it tombstones. EcContext builds its prefix from the request once,
before the parse, so the parse knows the path. A request with no
removed prefix keeps no other values and withdraws as before.

a_withdrawal_under_a_removed_prefix_tombstones_each_edge_cookies_row
seeds a live row for each of two Edge Cookies, withdraws on an
addressed request that carries both, and checks both rows. Without the
withdrawal_kv_keys line it fails on the row of the cookie under /.
cookie_values_gives_every_value_of_a_name_in_the_order_sent covers the
helper.
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15
@jwrosewell jwrosewell closed this Oct 18, 2026
@jwrosewell jwrosewell reopened this Oct 18, 2026
@jwrosewell
jwrosewell added this pull request to stack #1418 October 19, 2026 12:18
@jwrosewell
jwrosewell removed this pull request from stack #1418 October 19, 2026 16:30
@jwrosewell jwrosewell closed this Oct 19, 2026
@jwrosewell jwrosewell reopened this Oct 19, 2026
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell jwrosewell changed the title A publisher's cookies stay under its own path on a shared host Scope each publisher's cookies to its own path Oct 19, 2026
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:24

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant