From 5dbf29e6c4fdb733d37d400d0d9c895a82263b71 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 16:24:52 +0100 Subject: [PATCH 01/21] A design for keeping each publisher's cookies under its own path --- .../2026-10-15-cookies-by-path-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-15-cookies-by-path-design.md diff --git a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md new file mode 100644 index 0000000000..d0315de39c --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -0,0 +1,112 @@ +# Design Spec: Core keeps a publisher's cookies under that publisher's path when one host serves several + +**Status:** Proposed, 2026-10-15 +**Author:** 51Degrees (contributed), for Tech Lab review +**Related specs:** `2026-10-13-request-addressing-design.md`, `2026-10-12-prefix-module-design.md` +**Issues:** IABTechLab/trusted-server#1328 +**Last updated:** 2026-10-15 + +> **Why this spec exists.** On a host that serves several publishers by path, every cookie core writes has `Path=/`, so the browser sends each publisher's cookies to every other publisher's pages and origin. + +## 1. Problem + +Request addressing lets one host serve several publishers as `https://front.example//`. Each publisher's prefix module gives the cookies core names a name of their own. That keeps the Edge Cookie family, the eids cookie and the pull-sync marker apart by name. It does not keep them apart in the browser, and it does nothing for cookies whose names are fixed. + +1. **Core writes every cookie under `/`.** The Edge Cookie, its resolved marker, the pull-sync marker and the tester cookie all carry `Path=/`. A browser sends a cookie on every request whose path is under the cookie's `Path`, so it sends each publisher's cookies on every other publisher's requests. +2. **Some names are fixed.** The consent cookies (`euconsent-v2`, `__gpp`, `__gpp_sid`, `us_privacy`) and `sharedId` are named by the tools that write them, not by core. Consent given on one publisher's pages is read on another's. +3. **The origin receives them all.** `rewrite_origin_request` in `crates/trusted-server-core/src/publisher.rs` forwards the `Cookie` header to the chosen origin as the browser sent it. Each publisher's origin receives the cookies the browser holds for the others. +4. **The client script posts to the root.** The script calls root-relative endpoints such as `/auction`, which carry no publisher part, so on a host addressed by path those requests are addressed by host. + +## 2. Design + +Browsers already keep cookies apart by path. RFC 6265 section 5.1.4 sends a cookie only on a request whose path is the cookie's `Path` or below it. A cookie written with `Path=/publisher-a.example` is sent under `/publisher-a.example/` and never under `/publisher-b.example/`. Core uses that rule and adds no setting. + +**Every cookie core writes carries the removed prefix as its path.** On a request that carries a `RemovedPathPrefix`, a cookie core writes has `Path=` in place of `Path=/`. A request with no prefix writes `Path=/`, exactly as today. + +Every writer already takes the publisher's `PublicPrefix`, because the prefix names the cookie. The prefix now also carries the path its cookies live under, so a writer reads the name and the path from one value and cannot write one without the other. + +```rust +impl PublicPrefix { + /// The same prefix, its cookies written under `path`. + pub fn under_path(self, path: Option<&str>) -> Self; + + /// The prefix for `request`, its cookies under the prefix request + /// addressing removed, or under `/` when none was removed. + pub fn for_request(&self, request: &http::Request) -> Self; + + /// The `Path` attribute of every cookie written under this prefix. + pub fn cookie_path(&self) -> &str; +} +``` + +The writers, each of which now writes `Path={public.cookie_path()}`, are these. + +| Cookie | Written by | +| --------------------------------------- | ------------------------------------------------------------------- | +| Edge Cookie and its expiry | `format_set_cookie` in `ec/cookies.rs` | +| Resolved marker and its expiry | `set_resolved_marker_cookie`, `expire_ec_resolved_marker` | +| Pull-sync marker and its expiry | `format_marker_cookie` in `ec/pull_sync_marker.rs` | +| Tester cookie and its clearing | `format_tester_cookie`, `format_clear_tester_cookie` | + +The Edge Cookie context builds its prefix with `for_request` when it reads the request, so every cookie the finalize step writes takes the request's path. The resolve endpoint writes its cookies under that same prefix, read from the context and no longer from the runtime services. The tester routes take the request's prefix from their caller. + +**A removed prefix must be a cookie path.** A prefix is written into a `Set-Cookie` header, so `RequestAddress::new` now refuses a removed prefix that does not start with `/` or that holds anything but visible ASCII other than `;`. Such a request is refused as an invalid address, which the adapters answer `421 Misdirected Request`, before any settings are read. `under_path` applies the same check and keeps `/` for a prefix that fails it, with a warning, so a prefix built some other way cannot add an attribute to a cookie. + +**Reading the cookie a publisher's path holds.** A browser that holds two cookies of one name, one under `/` and one under `/publisher-a.example`, sends both on a request under `/publisher-a.example/`. RFC 6265 section 5.4 step 2 lists the one with the longer path first. + +- The consent cookies are read through `RequestInfo::cookie` in `crates/trusted-server-core/src/evidence.rs`, which returns the first pair of that name in the `Cookie` header. The permission signal modules read `euconsent-v2`, `__gpp`, `__gpp_sid` and `us_privacy` through it. A publisher's consent written under its path therefore already wins over a root one, on every request, and this change keeps that. +- The Edge Cookie and the pull-sync marker are read through `handle_request_cookies` in `crates/trusted-server-core/src/cookies.rs`, whose jar keeps the last pair of a name. The `cookie` crate's `CookieJar::add_original` replaces an entry of the same name (`cookie-0.18.1`, `src/jar.rs` line 153, keyed by name in `src/delta.rs`). On an addressed request the jar now keeps the first pair of each name, so a cookie under the publisher's path wins over a root one left from before. A request with no prefix keeps the last, as today. +- `sharedId` is read in core only to ask whether it is present, in `publisher.rs`, where order does not matter. Its value is read by the Fastly adapter's `extract_cookie_value`, which returns the first pair, as `RequestInfo::cookie` does. + +Consent cookies are written by the publisher's consent tool in the browser, not by core. A deployment that serves several publishers from one host configures each publisher's consent tool to write its cookies under that publisher's path. Core reads what the browser sends and keeps no list of which tool writes which cookie. + +**The `Cookie` header sent to the origin.** With every core cookie under its publisher's path, and each consent tool configured the same way, the browser never sends a cookie held under another publisher's path on this publisher's request. Such a cookie cannot reach this publisher's origin, so `rewrite_origin_request` still forwards the header as the browser sent it. Cookies written under `/` remain shared, and core cannot tell which publisher wrote one, so it forwards them to every origin on the host. This is the remaining limit, stated in section 3. + +**Why core writes the prefix into these cookies.** The request addressing spec leaves writing the prefix into a response to a deployment's middleware. Core is the only writer of its own cookies, and a middleware that rewrote their `Path` would have to recognise core's cookies by name in every response. The prefix is written only into the `Path` of the cookies core itself writes. Links in pages and cookies an origin writes remain a middleware's job. + +**Each adapter.** The extension is set in one place for all four adapters, so none changes. + +| Adapter | Where the request gets its prefix | Change in the adapter | +| ---------- | ------------------------------------------------------------------ | ----------------------------------------- | +| Fastly | The entry point inserts `RemovedPathPrefix` after asking the hook | The tester routes pass the request prefix | +| Axum | `addressed_router` in core | None | +| Cloudflare | `addressed_router` in core | None | +| Spin | `addressed_router` in core | None | + +**Configuration.** None. The path comes from the request, and a deployment that addresses no request by path writes `Path=/` as today. + +**Consent and privacy.** Keeping each publisher's cookies under its path stops one publisher's Edge Cookie, markers and consent reaching another publisher's pages and origin. A withdrawal expires the Edge Cookie under the same path it was written under, so the expiry reaches the cookie the browser holds for that publisher. A root Edge Cookie left from before this change is not expired by a request under a publisher's path, and it is no longer read there once a path-scoped one exists. + +**Failure handling.** A removed prefix that cannot be a cookie path is refused when the address is made, as above. A `Set-Cookie` value that fails to render is skipped with a warning, as today. + +**Rollout.** A request with no prefix writes `Path=/` and reads its jar as today, so a deployment that supplies no addressing hook, or one whose hook answers "by host", is unchanged. On a deployment already addressing by path, the first response after the change writes each core cookie under the publisher's path. The root cookie written before remains in the browser until it expires, and on that publisher's requests the path-scoped cookie wins because the jar keeps the first. + +**Tests.** In `crates/trusted-server-core`: + +- `prefix.rs`: a prefix with no path gives `/`, `for_request` takes the removed prefix, and a prefix holding `;` or not starting with `/` keeps `/`. +- `ec/cookies.rs`: the Edge Cookie, its expiry and the resolved marker carry `Path=/publisher-a.example` under that prefix, and `Path=/` without it. +- `ec/pull_sync_marker.rs` and `tester_cookie.rs`: the marker and the tester cookie carry the prefix path. +- `cookies.rs`: on an addressed request the first of two pairs of one name is read, and with no prefix the last is read, as today. +- `evidence.rs`: `RequestInfo::cookie` returns the first of two consent cookies of one name. +- `addressing.rs`: `RequestAddress::new` refuses a removed prefix holding `;` or not starting with `/`. + +## 3. What is not in this change + +- **Cookies under `/`.** A cookie written under `/`, by an origin, by a tool not configured for the publisher's path, or by core before this change, is sent under every publisher's path and forwarded to every origin on the host. Core cannot tell which publisher wrote it. A deployment that needs an origin's cookies kept apart rewrites their `Path` with a middleware of its own, as it puts the prefix into links. +- **The client script's endpoints and cookies.** The script calls root-relative endpoints, `/auction` in `crates/trusted-server-js/lib/src/core/request.ts`, `/_ts/api/v1/ec/resolve` in `integrations/ec_client_fixed`, `/_ts/page-bids` in `integrations/gpt`, and the auction endpoint default in `integrations/prebid`. Several integrations also write cookies with `Path=/` from the page, in `integrations/prebid`, `integrations/osano` and `integrations/sourcepoint`. On a host addressed by path these requests are addressed by host and those cookies are shared. The fix is for the server to tell the script the removed prefix in the page, as it tells it the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each endpoint and use it as each cookie's path. That change touches the script's core and four integrations and is left for a change of its own. +- **Expiring root cookies left from before.** Core expires a cookie under the path of the request it answers, so a root cookie written before this change stays until its `Max-Age` ends. +- **Partitioning other first-party storage.** Local storage and other storage the page script keeps belong to the host, not the path. + +## 4. Acceptance + +The tests listed under "Tests" in section 2 pass. `cargo test -p trusted-server-core`, `cargo clippy -p trusted-server-core --all-targets -- -D warnings` and `cargo fmt --all -- --check` pass. With no `RemovedPathPrefix` on a request, every `Set-Cookie` core writes is byte for byte as before, which the existing cookie tests check unchanged. + +## Review log + +No review rounds yet. + +## Revision record + +| Date | Change | +| ---------- | ------------ | +| 2026-10-15 | First draft. | From e09c9e2aecb036959e1828b77a3061e6af9e228d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 16:44:33 +0100 Subject: [PATCH 02/21] A removed path prefix must be one a cookie can carry as its path --- crates/trusted-server-core/src/addressing.rs | 41 +++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/addressing.rs b/crates/trusted-server-core/src/addressing.rs index 3b69686a5d..f51a68592a 100644 --- a/crates/trusted-server-core/src/addressing.rs +++ b/crates/trusted-server-core/src/addressing.rs @@ -99,13 +99,17 @@ impl RequestAddress { /// /// Returns [`AddressingError::InvalidPath`] when `path` does not start /// with `/`, is not percent-encoded ASCII, carries a `?` or a `#`, or is - /// not a valid URI path. + /// not a valid URI path, or when `removed_prefix` is neither empty nor a + /// cookie path, as [`is_cookie_path`] says, because core writes it as + /// the `Path` of its cookies. pub fn new( selector_values: Vec, path: String, removed_prefix: String, ) -> Result> { - if !is_continuing_path(&path) { + if !is_continuing_path(&path) + || !(removed_prefix.is_empty() || is_cookie_path(&removed_prefix)) + { return Err(Report::new(AddressingError::InvalidPath)); } Ok(Self { @@ -279,6 +283,19 @@ fn addressed_handler( /// Whether `path` is one a request can continue with, which is a /// percent-encoded ASCII path as a request carries it, so every adapter /// routes the same path. +/// Whether `path` can be the `Path` attribute of a cookie. +/// +/// It starts with `/` and holds only visible ASCII other than `;`, so +/// written into a `Set-Cookie` header it cannot end the attribute and add +/// another. +#[must_use] +pub fn is_cookie_path(path: &str) -> bool { + path.starts_with('/') + && path + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b';') +} + fn is_continuing_path(path: &str) -> bool { path.starts_with('/') && path.is_ascii() @@ -414,6 +431,26 @@ mod tests { } } + #[test] + fn a_removed_prefix_is_empty_or_a_cookie_path() { + for prefix in ["", "/publisher-a.example", "/a%3Bb"] { + assert!( + RequestAddress::new(Vec::new(), "/news".to_owned(), prefix.to_owned()).is_ok(), + "should accept `{prefix}`" + ); + } + for prefix in [ + "publisher-a.example", + "/a;Domain=example.com", + "/a b", + "/\u{e9}", + ] { + let error = RequestAddress::new(Vec::new(), "/news".to_owned(), prefix.to_owned()) + .expect_err("should refuse a prefix that cannot be a cookie path"); + assert_eq!(*error.current_context(), AddressingError::InvalidPath); + } + } + #[test] fn the_error_carries_nothing_from_the_request() { let error = RequestAddress::new(Vec::new(), "/private?id=reader".to_owned(), String::new()) From b16077a7d1b0ef4dc24c8b098f7acbc3ca6b5f47 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 16:44:38 +0100 Subject: [PATCH 03/21] Core writes each cookie under the path request addressing removed --- crates/trusted-server-core/src/ec/cookies.rs | 47 +++++++++- crates/trusted-server-core/src/ec/mod.rs | 2 +- .../src/ec/pull_sync_marker.rs | 14 ++- crates/trusted-server-core/src/ec/resolve.rs | 4 +- crates/trusted-server-core/src/prefix.rs | 85 +++++++++++++++++++ .../trusted-server-core/src/tester_cookie.rs | 29 ++++++- 6 files changed, 171 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac67fbbe07..2134704eff 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -5,7 +5,9 @@ //! Cookie attributes follow current best practices: //! //! - `Domain` is computed as `.{publisher.domain}` for subdomain coverage -//! - `Path=/` makes the cookie available on all paths +//! - `Path` is the prefix's cookie path, `/` unless request addressing +//! removed a prefix, so publishers served from one host by path keep +//! their cookies apart //! - `Secure` restricts to HTTPS //! - `SameSite=Lax` provides CSRF protection while allowing top-level navigations //! - `Max-Age` of 1 year (or 0 to expire) @@ -53,8 +55,9 @@ pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { /// attributes (e.g. adding `Partitioned`) only need updating in one place. fn format_set_cookie(public: &PublicPrefix, domain: &str, value: &str, max_age: i32) -> String { format!( - "{}={value}; Domain={domain}; Path=/; Secure; SameSite=Lax; Max-Age={max_age}; HttpOnly", + "{}={value}; Domain={domain}; Path={}; Secure; SameSite=Lax; Max-Age={max_age}; HttpOnly", public.cookie_name(COOKIE_EC), + public.cookie_path(), ) } @@ -161,9 +164,10 @@ pub fn expire_ec_resolved_marker( response: &mut Response, ) { let marker = format!( - "{}=; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age=0", + "{}=; Domain={}; Path={}; Secure; SameSite=Lax; Max-Age=0", public.cookie_name(COOKIE_EC_RESOLVED), settings.publisher.ec_cookie_domain(), + public.cookie_path(), ); match HeaderValue::from_str(&marker) { Ok(val) => { @@ -190,9 +194,10 @@ pub(crate) fn set_resolved_marker_cookie( response: &mut Response, ) { let cookie = format!( - "{}=1; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}", + "{}=1; Domain={}; Path={}; Secure; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}", public.cookie_name(COOKIE_EC_RESOLVED), settings.publisher.ec_cookie_domain(), + public.cookie_path(), ); match HeaderValue::from_str(&cookie) { Ok(val) => { @@ -278,6 +283,40 @@ mod tests { assert_eq!(names, ["_ex-ec", "_ex-ecr", "_ex-ec", "_ex-ecr"]); } + fn set_cookie_paths(public: &PublicPrefix) -> Vec { + let settings = create_test_settings(); + let mut response = empty_response(); + set_ec_cookie(&settings, public, &mut response, TEST_EC_ID); + set_resolved_marker_cookie(&settings, public, &mut response); + expire_ec_cookie(&settings, public, &mut response); + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + let text = value.to_str().expect("should be valid UTF-8"); + text.split("; ") + .find_map(|attribute| attribute.strip_prefix("Path=")) + .expect("should carry a Path") + .to_owned() + }) + .collect() + } + + /// On a request addressed by path, every cookie this file writes is + /// under the removed prefix, so another publisher's pages never get it. + #[test] + fn each_cookie_is_under_the_removed_prefix_on_an_addressed_request() { + let public = PublicPrefix::default().under_path(Some("/publisher-a.example")); + + assert_eq!(set_cookie_paths(&public), ["/publisher-a.example"; 4]); + } + + #[test] + fn each_cookie_is_under_the_root_with_no_removed_prefix() { + assert_eq!(set_cookie_paths(&PublicPrefix::default()), ["/"; 4]); + } + /// A valid EC ID for use in cookie tests. const TEST_EC_ID: &str = "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffff0000000011111111.abcXYZ"; diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 081a6f829c..597690660c 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -574,7 +574,7 @@ impl EcContext { response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, - public_prefix: services.public_prefix().clone(), + public_prefix: services.public_prefix().for_request(req), client_eids: None, module_eids, pull_sync_marker: PullSyncMarkerState::from_cookie(parsed.pull_sync_marker), diff --git a/crates/trusted-server-core/src/ec/pull_sync_marker.rs b/crates/trusted-server-core/src/ec/pull_sync_marker.rs index 446bd9ead9..31ec31c79e 100644 --- a/crates/trusted-server-core/src/ec/pull_sync_marker.rs +++ b/crates/trusted-server-core/src/ec/pull_sync_marker.rs @@ -213,7 +213,8 @@ fn format_marker_cookie(public: &PublicPrefix, value: &str, max_age: u64) -> Str // request host, so a Domain attribute would allow replay across sibling // hosts that share the domain-wide EC cookie and passphrase. let name = public.cookie_name(COOKIE_EC_PULL_COMPLETE); - format!("{name}={value}; Path=/; Secure; SameSite=Lax; Max-Age={max_age}; HttpOnly") + let path = public.cookie_path(); + format!("{name}={value}; Path={path}; Secure; SameSite=Lax; Max-Age={max_age}; HttpOnly") } #[cfg(test)] @@ -732,4 +733,15 @@ mod tests { ); assert!(!cookie.contains("Domain="), "marker should be host-only"); } + + #[test] + fn marker_cookie_is_under_the_removed_prefix_on_an_addressed_request() { + let public = PublicPrefix::default().under_path(Some("/publisher-a.example")); + let cookie = format_marker_cookie(&public, "value", MARKER_MAX_AGE_SECS); + assert_eq!( + cookie, + "ts-ec-pull-complete=value; Path=/publisher-a.example; Secure; SameSite=Lax; \ + Max-Age=3600; HttpOnly" + ); + } } diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs index 1ef709a1fd..507a85e5aa 100644 --- a/crates/trusted-server-core/src/ec/resolve.rs +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -243,11 +243,11 @@ pub async fn handle_ec_resolve( // carry, which a replacing write would drop. apply_module_response_headers(response.headers_mut(), generated.response_headers); - set_ec_cookie(settings, services.public_prefix(), &mut response, &ec_id); + set_ec_cookie(settings, ec_context.public_prefix(), &mut response, &ec_id); // The Edge Cookie is HttpOnly, so the page script cannot see it; the // non-HttpOnly marker tells the script the resolve succeeded so it does // not post again on every page view. - set_resolved_marker_cookie(settings, services.public_prefix(), &mut response); + set_resolved_marker_cookie(settings, ec_context.public_prefix(), &mut response); Ok(response) } diff --git a/crates/trusted-server-core/src/prefix.rs b/crates/trusted-server-core/src/prefix.rs index 44d8c1b76b..c9c2b43630 100644 --- a/crates/trusted-server-core/src/prefix.rs +++ b/crates/trusted-server-core/src/prefix.rs @@ -65,6 +65,7 @@ pub const PUBLIC_PREFIX_ATTRIBUTE: &str = "data-ts-public-prefix"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PublicPrefix { dotted: String, + cookie_path: Option, } impl PublicPrefix { @@ -96,6 +97,7 @@ impl PublicPrefix { } Some(Self { dotted: parts.join("."), + cookie_path: None, }) } @@ -113,6 +115,48 @@ impl PublicPrefix { format!("{}-{name}", self.dotted) } + /// The same prefix, its cookies written under `path`. + /// + /// `None` or an empty path writes them under `/`. So does a path that + /// cannot be a cookie's `Path`, logged, so a value written into a + /// `Set-Cookie` header can never add an attribute. + #[must_use] + pub fn under_path(self, path: Option<&str>) -> Self { + let cookie_path = match path { + None | Some("") => None, + Some(path) if crate::addressing::is_cookie_path(path) => Some(path.to_owned()), + Some(_) => { + log::warn!("Writing cookies under / as the removed prefix is not a cookie path"); + None + } + }; + Self { + cookie_path, + ..self + } + } + + /// The prefix for `request`, its cookies written under the prefix + /// request addressing removed, or under `/` when none was removed. + /// + /// A browser sends a cookie only under its `Path`, so publishers served + /// from one host by path never receive each other's cookies. + #[must_use] + pub fn for_request(&self, request: &http::Request) -> Self { + self.clone().under_path( + request + .extensions() + .get::() + .map(crate::addressing::RemovedPathPrefix::as_str), + ) + } + + /// The `Path` attribute of every cookie written under this prefix. + #[must_use] + pub fn cookie_path(&self) -> &str { + self.cookie_path.as_deref().unwrap_or("/") + } + /// Whether `cookie` is in the namespace this prefix starts, which is every /// name [`Self::cookie_name`] gives, compared without regard to case. #[must_use] @@ -180,6 +224,7 @@ impl TrustedServerPrefix { pub fn public() -> PublicPrefix { PublicPrefix { dotted: TRUSTED_SERVER_HEAD.to_owned(), + cookie_path: None, } } @@ -359,6 +404,46 @@ mod tests { use super::*; use crate::module_context::test_support::request; + #[test] + fn cookies_are_under_the_root_with_no_removed_prefix() { + assert_eq!(PublicPrefix::default().cookie_path(), "/"); + assert_eq!( + PublicPrefix::default().under_path(Some("")).cookie_path(), + "/" + ); + } + + #[test] + fn cookies_are_under_the_prefix_the_request_had_removed() { + let mut addressed = http::Request::new(()); + addressed + .extensions_mut() + .insert(crate::addressing::RemovedPathPrefix::new( + "/publisher-a.example".to_owned(), + )); + let public = PublicPrefix::default(); + + assert_eq!( + public.for_request(&addressed).cookie_path(), + "/publisher-a.example" + ); + assert_eq!( + public.for_request(&http::Request::new(())).cookie_path(), + "/" + ); + } + + #[test] + fn a_prefix_that_is_not_a_cookie_path_keeps_the_root() { + for path in ["/a; Domain=example.com", "a", "/a b", "/\u{e9}"] { + assert_eq!( + PublicPrefix::default().under_path(Some(path)).cookie_path(), + "/", + "{path}" + ); + } + } + fn settings_for(domain: &str) -> Settings { let mut settings = Settings::default(); settings.publisher.domain = domain.to_owned(); diff --git a/crates/trusted-server-core/src/tester_cookie.rs b/crates/trusted-server-core/src/tester_cookie.rs index 8dc59647ab..39fd67ecf0 100644 --- a/crates/trusted-server-core/src/tester_cookie.rs +++ b/crates/trusted-server-core/src/tester_cookie.rs @@ -19,9 +19,10 @@ use crate::settings::Settings; /// Formats the tester cookie `Set-Cookie` header value. fn format_tester_cookie(public: &PublicPrefix, domain: &str, max_age_seconds: u32) -> String { format!( - "{}=true; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}", + "{}=true; Domain={}; Path={}; Secure; SameSite=Lax; Max-Age={}", public.cookie_name(COOKIE_TESTER), domain, + public.cookie_path(), max_age_seconds, ) } @@ -29,9 +30,10 @@ fn format_tester_cookie(public: &PublicPrefix, domain: &str, max_age_seconds: u3 /// Formats the tester cookie clearing `Set-Cookie` header value. fn format_clear_tester_cookie(public: &PublicPrefix, domain: &str) -> String { format!( - "{}=; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age=0", + "{}=; Domain={}; Path={}; Secure; SameSite=Lax; Max-Age=0", public.cookie_name(COOKIE_TESTER), domain, + public.cookie_path(), ) } @@ -201,6 +203,29 @@ mod tests { } } + #[test] + fn tester_cookie_is_under_the_removed_prefix_on_an_addressed_request() { + let mut settings = create_test_settings(); + settings.tester_cookie.enabled = true; + let public = PublicPrefix::default().under_path(Some("/publisher-a.example")); + + for response in [ + handle_set_tester(&settings, &public).expect("should set the tester cookie"), + handle_clear_tester(&settings, &public).expect("should clear the tester cookie"), + ] { + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should write the tester cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert!( + set_cookie.contains("; Path=/publisher-a.example; "), + "{set_cookie}" + ); + } + } + #[test] fn tester_cookie_route_is_disabled_by_default() { let settings = create_test_settings(); From f98a4ba0070f69be457715f1a730c2846f2dc727 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 16:44:42 +0100 Subject: [PATCH 04/21] On an addressed request the cookie jar keeps the first cookie of each name --- crates/trusted-server-core/src/cookies.rs | 58 +++++++++++++++++++++- crates/trusted-server-core/src/evidence.rs | 9 ++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index e778defebf..5c603ce8c1 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -29,10 +29,29 @@ pub fn parse_cookies_to_jar(s: &str) -> CookieJar { jar } +/// Parses a cookie string into a [`CookieJar`] that keeps the first cookie +/// of each name. +/// +/// A browser lists the cookie with the longer `Path` first, so on a request +/// under a publisher's path this keeps the publisher's cookie over one of +/// the same name written under `/`. +fn parse_cookies_to_jar_keeping_first(s: &str) -> CookieJar { + let mut jar = CookieJar::new(); + for cookie in Cookie::split_parse(s.trim().to_owned()).filter_map(Result::ok) { + if jar.get(cookie.name()).is_none() { + jar.add_original(cookie); + } + } + jar +} + /// Extracts and parses cookies from an HTTP request. /// /// Attempts to parse the Cookie header into a [`CookieJar`] for easy access -/// to individual cookies. +/// to individual cookies. On a request request addressing gave a removed +/// prefix, the first cookie of each name is kept, the one under the +/// publisher's path. Otherwise the last is kept, as [`parse_cookies_to_jar`] +/// keeps it. /// /// # Errors /// @@ -47,7 +66,15 @@ pub fn handle_request_cookies( .change_context(TrustedServerError::InvalidHeaderValue { message: "Cookie header contains invalid UTF-8".to_owned(), })?; - let jar = parse_cookies_to_jar(header_value_str); + let addressed = req + .extensions() + .get::() + .is_some(); + let jar = if addressed { + parse_cookies_to_jar_keeping_first(header_value_str) + } else { + parse_cookies_to_jar(header_value_str) + }; Ok(Some(jar)) } else { log::debug!("No cookie header found in request"); @@ -128,6 +155,33 @@ mod tests { assert_eq!(jar.get("c1").expect("should have cookie c1").value(), "v2"); } + /// A browser lists the cookie with the longer path first, so on an + /// addressed request the publisher's own cookie is the one read. + #[test] + fn an_addressed_request_reads_the_first_cookie_of_a_name() { + let mut req = build_request(Some("ts-ec=publisher; ts-ec=root; other=1")); + req.extensions_mut() + .insert(crate::addressing::RemovedPathPrefix::new( + "/publisher-a.example".to_owned(), + )); + let jar = handle_request_cookies(&req) + .expect("should parse cookies") + .expect("should have cookie jar"); + + assert_eq!(jar.get("ts-ec").map(Cookie::value), Some("publisher")); + assert_eq!(jar.get("other").map(Cookie::value), Some("1")); + } + + #[test] + fn a_request_with_no_removed_prefix_reads_the_last_cookie_of_a_name() { + let req = build_request(Some("ts-ec=publisher; ts-ec=root")); + let jar = handle_request_cookies(&req) + .expect("should parse cookies") + .expect("should have cookie jar"); + + assert_eq!(jar.get("ts-ec").map(Cookie::value), Some("root")); + } + #[test] fn test_parse_cookies_to_jar_empty() { let cookie_str = ""; diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 826735da56..dc48cf2910 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -287,6 +287,15 @@ mod cookie_tests { OwnedRequestInfo::new(String::new(), headers) } + /// A browser lists the cookie with the longer path first, so the + /// consent a publisher's tool wrote under its path wins over one of the + /// same name under `/`, on an addressed request as on any other. + #[test] + fn reads_the_first_of_two_cookies_of_one_name() { + let info = with_cookie_header("euconsent-v2=CP-publisher; euconsent-v2=CP-root"); + assert_eq!(info.cookie("euconsent-v2"), Some("CP-publisher")); + } + #[test] fn reads_a_cookie_by_name_from_among_several() { let info = with_cookie_header("a=1; euconsent-v2=CP-abc; b=2"); From 185557ad2bd1522198fb970237726c377a99e797 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 16:44:48 +0100 Subject: [PATCH 05/21] The design records the tester routes on Fastly and the empty prefix --- .../specs/2026-10-15-cookies-by-path-design.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md index d0315de39c..2e81cdcbca 100644 --- a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -48,9 +48,9 @@ The writers, each of which now writes `Path={public.cookie_path()}`, are these. | Pull-sync marker and its expiry | `format_marker_cookie` in `ec/pull_sync_marker.rs` | | Tester cookie and its clearing | `format_tester_cookie`, `format_clear_tester_cookie` | -The Edge Cookie context builds its prefix with `for_request` when it reads the request, so every cookie the finalize step writes takes the request's path. The resolve endpoint writes its cookies under that same prefix, read from the context and no longer from the runtime services. The tester routes take the request's prefix from their caller. +The Edge Cookie context builds its prefix with `for_request` when it reads the request, so every cookie the finalize step writes takes the request's path. The resolve endpoint writes its cookies under that same prefix, read from the context and no longer from the runtime services. The tester routes write under the prefix their caller passes them. -**A removed prefix must be a cookie path.** A prefix is written into a `Set-Cookie` header, so `RequestAddress::new` now refuses a removed prefix that does not start with `/` or that holds anything but visible ASCII other than `;`. Such a request is refused as an invalid address, which the adapters answer `421 Misdirected Request`, before any settings are read. `under_path` applies the same check and keeps `/` for a prefix that fails it, with a warning, so a prefix built some other way cannot add an attribute to a cookie. +**A removed prefix must be a cookie path.** A prefix is written into a `Set-Cookie` header, so `RequestAddress::new` now refuses a removed prefix that does not start with `/` or that holds anything but visible ASCII other than `;`. An empty prefix removes nothing and is still accepted, and its cookies are written under `/`. Such a request is refused as an invalid address, which the adapters answer `421 Misdirected Request`, before any settings are read. `under_path` applies the same check and keeps `/` for a prefix that fails it, with a warning, so a prefix built some other way cannot add an attribute to a cookie. **Reading the cookie a publisher's path holds.** A browser that holds two cookies of one name, one under `/` and one under `/publisher-a.example`, sends both on a request under `/publisher-a.example/`. RFC 6265 section 5.4 step 2 lists the one with the longer path first. @@ -68,7 +68,7 @@ Consent cookies are written by the publisher's consent tool in the browser, not | Adapter | Where the request gets its prefix | Change in the adapter | | ---------- | ------------------------------------------------------------------ | ----------------------------------------- | -| Fastly | The entry point inserts `RemovedPathPrefix` after asking the hook | The tester routes pass the request prefix | +| Fastly | The entry point inserts `RemovedPathPrefix` after asking the hook | None, see section 3 for the tester routes | | Axum | `addressed_router` in core | None | | Cloudflare | `addressed_router` in core | None | | Spin | `addressed_router` in core | None | @@ -83,17 +83,18 @@ Consent cookies are written by the publisher's consent tool in the browser, not **Tests.** In `crates/trusted-server-core`: -- `prefix.rs`: a prefix with no path gives `/`, `for_request` takes the removed prefix, and a prefix holding `;` or not starting with `/` keeps `/`. +- `prefix.rs`: a prefix with no path or an empty one gives `/`, `for_request` takes the removed prefix, and a prefix holding `;`, a space or non-ASCII, or not starting with `/`, keeps `/`. - `ec/cookies.rs`: the Edge Cookie, its expiry and the resolved marker carry `Path=/publisher-a.example` under that prefix, and `Path=/` without it. - `ec/pull_sync_marker.rs` and `tester_cookie.rs`: the marker and the tester cookie carry the prefix path. - `cookies.rs`: on an addressed request the first of two pairs of one name is read, and with no prefix the last is read, as today. - `evidence.rs`: `RequestInfo::cookie` returns the first of two consent cookies of one name. -- `addressing.rs`: `RequestAddress::new` refuses a removed prefix holding `;` or not starting with `/`. +- `addressing.rs`: `RequestAddress::new` accepts an empty prefix or a cookie path, and refuses a removed prefix holding `;`, a space or non-ASCII, or not starting with `/`. ## 3. What is not in this change - **Cookies under `/`.** A cookie written under `/`, by an origin, by a tool not configured for the publisher's path, or by core before this change, is sent under every publisher's path and forwarded to every origin on the host. Core cannot tell which publisher wrote it. A deployment that needs an origin's cookies kept apart rewrites their `Path` with a middleware of its own, as it puts the prefix into links. - **The client script's endpoints and cookies.** The script calls root-relative endpoints, `/auction` in `crates/trusted-server-js/lib/src/core/request.ts`, `/_ts/api/v1/ec/resolve` in `integrations/ec_client_fixed`, `/_ts/page-bids` in `integrations/gpt`, and the auction endpoint default in `integrations/prebid`. Several integrations also write cookies with `Path=/` from the page, in `integrations/prebid`, `integrations/osano` and `integrations/sourcepoint`. On a host addressed by path these requests are addressed by host and those cookies are shared. The fix is for the server to tell the script the removed prefix in the page, as it tells it the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each endpoint and use it as each cookie's path. That change touches the script's core and four integrations and is left for a change of its own. +- **The tester cookie on Fastly.** The Fastly adapter passes the runtime services' prefix to the tester routes, so on an addressed request the tester cookie is still written under `/`. Passing `services.public_prefix().for_request(&req)` there is a one-line change in the adapter, left out because this change builds and tests core alone. - **Expiring root cookies left from before.** Core expires a cookie under the path of the request it answers, so a root cookie written before this change stays until its `Max-Age` ends. - **Partitioning other first-party storage.** Local storage and other storage the page script keeps belong to the host, not the path. From db2065ceebf0fb6f4ad412d45074ed2f9e53b9ad Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 16:51:09 +0100 Subject: [PATCH 06/21] The Fastly tester routes write under the path request addressing removed --- .../trusted-server-adapter-fastly/src/app.rs | 32 +++++++++++++++++-- .../2026-10-15-cookies-by-path-design.md | 1 - 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 1ebed93347..69f610dd5c 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -962,10 +962,10 @@ async fn run_named_route( } } NamedRouteHandler::SetTester => { - handle_set_tester(&state.settings, services.public_prefix()) + handle_set_tester(&state.settings, &services.public_prefix().for_request(&req)) } NamedRouteHandler::ClearTester => { - handle_clear_tester(&state.settings, services.public_prefix()) + handle_clear_tester(&state.settings, &services.public_prefix().for_request(&req)) } NamedRouteHandler::Permissions | NamedRouteHandler::Config | NamedRouteHandler::Data => { unreachable!("the inspection endpoints are answered before EC setup") @@ -2411,6 +2411,34 @@ mod tests { ); } + /// On a request addressed by path, the tester cookie is written under + /// the removed prefix, as every other cookie core writes is. + #[test] + fn dispatch_set_tester_writes_under_the_removed_prefix() { + let mut settings = test_settings(); + settings.tester_cookie.enabled = true; + let state = app_state_for_settings(settings); + let router = TrustedServerApp::routes_for_state(&state); + let mut request = empty_request(Method::GET, "/_ts/set-tester"); + request + .extensions_mut() + .insert(trusted_server_core::addressing::RemovedPathPrefix::new( + "/publisher-a.example".to_owned(), + )); + let response = route(&router, request); + + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set tester cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert!( + set_cookie.contains("; Path=/publisher-a.example; "), + "tester cookie should be under the removed prefix: {set_cookie}" + ); + } + #[test] fn dispatch_permissions_answers_as_data_and_as_a_page_and_writes_nothing() { let router = test_router(); diff --git a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md index 2e81cdcbca..7546ab973f 100644 --- a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -94,7 +94,6 @@ Consent cookies are written by the publisher's consent tool in the browser, not - **Cookies under `/`.** A cookie written under `/`, by an origin, by a tool not configured for the publisher's path, or by core before this change, is sent under every publisher's path and forwarded to every origin on the host. Core cannot tell which publisher wrote it. A deployment that needs an origin's cookies kept apart rewrites their `Path` with a middleware of its own, as it puts the prefix into links. - **The client script's endpoints and cookies.** The script calls root-relative endpoints, `/auction` in `crates/trusted-server-js/lib/src/core/request.ts`, `/_ts/api/v1/ec/resolve` in `integrations/ec_client_fixed`, `/_ts/page-bids` in `integrations/gpt`, and the auction endpoint default in `integrations/prebid`. Several integrations also write cookies with `Path=/` from the page, in `integrations/prebid`, `integrations/osano` and `integrations/sourcepoint`. On a host addressed by path these requests are addressed by host and those cookies are shared. The fix is for the server to tell the script the removed prefix in the page, as it tells it the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each endpoint and use it as each cookie's path. That change touches the script's core and four integrations and is left for a change of its own. -- **The tester cookie on Fastly.** The Fastly adapter passes the runtime services' prefix to the tester routes, so on an addressed request the tester cookie is still written under `/`. Passing `services.public_prefix().for_request(&req)` there is a one-line change in the adapter, left out because this change builds and tests core alone. - **Expiring root cookies left from before.** Core expires a cookie under the path of the request it answers, so a root cookie written before this change stays until its `Max-Age` ends. - **Partitioning other first-party storage.** Local storage and other storage the page script keeps belong to the host, not the path. From 963bf8bd325a1b0cd1277bad2dae7983256f609f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 18:09:06 +0100 Subject: [PATCH 07/21] The cookies by path design states what the code does for a refused prefix 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. --- .../2026-10-15-cookies-by-path-design.md | 107 ++++++++++-------- 1 file changed, 62 insertions(+), 45 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md index 7546ab973f..f083e77bfa 100644 --- a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -6,22 +6,22 @@ **Issues:** IABTechLab/trusted-server#1328 **Last updated:** 2026-10-15 -> **Why this spec exists.** On a host that serves several publishers by path, every cookie core writes has `Path=/`, so the browser sends each publisher's cookies to every other publisher's pages and origin. +> **Why this spec exists.** On a host that serves several publishers by path, every cookie core writes has `Path=/`, so the browser sends each publisher's cookies to every other publisher's pages and origin. This change writes the cookies core owns under each publisher's path. Cookies written under `/` by an origin, a module or the page script stay shared, and section 3 lists them. ## 1. Problem Request addressing lets one host serve several publishers as `https://front.example//`. Each publisher's prefix module gives the cookies core names a name of their own. That keeps the Edge Cookie family, the eids cookie and the pull-sync marker apart by name. It does not keep them apart in the browser, and it does nothing for cookies whose names are fixed. 1. **Core writes every cookie under `/`.** The Edge Cookie, its resolved marker, the pull-sync marker and the tester cookie all carry `Path=/`. A browser sends a cookie on every request whose path is under the cookie's `Path`, so it sends each publisher's cookies on every other publisher's requests. -2. **Some names are fixed.** The consent cookies (`euconsent-v2`, `__gpp`, `__gpp_sid`, `us_privacy`) and `sharedId` are named by the tools that write them, not by core. Consent given on one publisher's pages is read on another's. -3. **The origin receives them all.** `rewrite_origin_request` in `crates/trusted-server-core/src/publisher.rs` forwards the `Cookie` header to the chosen origin as the browser sent it. Each publisher's origin receives the cookies the browser holds for the others. +2. **Some names are fixed.** The consent cookies (`euconsent-v2`, `__gpp`, `__gpp_sid` and `us_privacy`), the preference cookie `__mtm_pref` and `sharedId` are named by the tools that write them, not by core. Consent given on one publisher's pages is read on another's. +3. **The origin receives them all.** `rewrite_origin_request` in `crates/trusted-server-core/src/publisher.rs` forwards the `Cookie` header to the chosen origin as the browser sent it. Each publisher's origin receives the cookies the browser holds for the others. The one exception is an origin whose requests are signed, because `sign_publisher_origin_request` removes the `Cookie` header from every request it signs. 4. **The client script posts to the root.** The script calls root-relative endpoints such as `/auction`, which carry no publisher part, so on a host addressed by path those requests are addressed by host. ## 2. Design -Browsers already keep cookies apart by path. RFC 6265 section 5.1.4 sends a cookie only on a request whose path is the cookie's `Path` or below it. A cookie written with `Path=/publisher-a.example` is sent under `/publisher-a.example/` and never under `/publisher-b.example/`. Core uses that rule and adds no setting. +Browsers already keep cookies apart by path. RFC 6265 sections 5.1.4 and 5.4 send a cookie only on a request whose path matches the cookie's `Path`, which means the same path or one below it. A cookie written with `Path=/publisher-a.example` is sent under `/publisher-a.example/` and never under `/publisher-b.example/`. Core uses that rule and adds no setting. -**Every cookie core writes carries the removed prefix as its path.** On a request that carries a `RemovedPathPrefix`, a cookie core writes has `Path=` in place of `Path=/`. A request with no prefix writes `Path=/`, exactly as today. +**Every cookie core writes carries the removed prefix as its path.** An addressed request is one the request addressing hook answered with an address, so it carries a `RemovedPathPrefix`, even an empty one. A request addressed by host carries none. On an addressed request, a cookie core writes has `Path=` in place of `Path=/`. A request with no prefix, or an empty one, writes `Path=/`, exactly as today. Every writer already takes the publisher's `PublicPrefix`, because the prefix names the cookie. The prefix now also carries the path its cookies live under, so a writer reads the name and the path from one value and cannot write one without the other. @@ -41,72 +41,89 @@ impl PublicPrefix { The writers, each of which now writes `Path={public.cookie_path()}`, are these. -| Cookie | Written by | -| --------------------------------------- | ------------------------------------------------------------------- | -| Edge Cookie and its expiry | `format_set_cookie` in `ec/cookies.rs` | -| Resolved marker and its expiry | `set_resolved_marker_cookie`, `expire_ec_resolved_marker` | -| Pull-sync marker and its expiry | `format_marker_cookie` in `ec/pull_sync_marker.rs` | -| Tester cookie and its clearing | `format_tester_cookie`, `format_clear_tester_cookie` | +| Cookie | Written by | +| ------------------------------- | --------------------------------------------------------- | +| Edge Cookie and its expiry | `format_set_cookie` in `ec/cookies.rs` | +| Resolved marker and its expiry | `set_resolved_marker_cookie`, `expire_ec_resolved_marker` | +| Pull-sync marker and its expiry | `format_marker_cookie` in `ec/pull_sync_marker.rs` | +| Tester cookie and its clearing | `format_tester_cookie`, `format_clear_tester_cookie` | -The Edge Cookie context builds its prefix with `for_request` when it reads the request, so every cookie the finalize step writes takes the request's path. The resolve endpoint writes its cookies under that same prefix, read from the context and no longer from the runtime services. The tester routes write under the prefix their caller passes them. +`EcContext` builds its prefix with `for_request` when it reads the request, so every cookie the finalize step writes takes the request's path. The resolve endpoint writes its cookies under that same prefix, read from the context and no longer from the runtime services. The tester routes write under the prefix their caller passes them, and only the Fastly adapter serves those routes. -**A removed prefix must be a cookie path.** A prefix is written into a `Set-Cookie` header, so `RequestAddress::new` now refuses a removed prefix that does not start with `/` or that holds anything but visible ASCII other than `;`. An empty prefix removes nothing and is still accepted, and its cookies are written under `/`. Such a request is refused as an invalid address, which the adapters answer `421 Misdirected Request`, before any settings are read. `under_path` applies the same check and keeps `/` for a prefix that fails it, with a warning, so a prefix built some other way cannot add an attribute to a cookie. +**A removed prefix must be a cookie path.** A prefix is written into a `Set-Cookie` header, so `addressing::is_cookie_path` accepts only a prefix that starts with `/` and holds nothing but visible ASCII other than `;`. The check sets no limit on length. `RequestAddress::new` returns `AddressingError::InvalidPath` for a removed prefix that fails the check, and still accepts an empty prefix, which removes nothing and writes its cookies under `/`. `under_path` applies the same check and keeps `/` for a prefix that fails it, with a warning, so a prefix built some other way cannot add an attribute to a cookie. -**Reading the cookie a publisher's path holds.** A browser that holds two cookies of one name, one under `/` and one under `/publisher-a.example`, sends both on a request under `/publisher-a.example/`. RFC 6265 section 5.4 step 2 lists the one with the longer path first. +**What a refused prefix becomes.** `RequestAddress::new` hands its error to the hook, and the hook decides what the request becomes. `Addressing` has only `ByHost` and `Addressed`, so a hook cannot refuse a request, and no adapter calls `new`. The hook in the rustdoc of `addressing.rs`, and the one in the request addressing spec, map the error to `ByHost`. A request such as `/a;b/news` is then addressed by its host, keeps its whole path and has no removed prefix. On a Fastly service whose key selector carries `{host}`, that request is served from the front host's own blob when the front host has one. Before this change the same request carried the selector value `a;b`, which `normalized_host` drops because `a;b` is not a host name, so no blob was found and the Fastly adapter answered `421 Misdirected Request`. That 421 is the adapter's answer to a missing blob, and no adapter answers 421 because a prefix is not a cookie path. `AddressingError::InvalidPath` still describes only the continuing path. -- The consent cookies are read through `RequestInfo::cookie` in `crates/trusted-server-core/src/evidence.rs`, which returns the first pair of that name in the `Cookie` header. The permission signal modules read `euconsent-v2`, `__gpp`, `__gpp_sid` and `us_privacy` through it. A publisher's consent written under its path therefore already wins over a root one, on every request, and this change keeps that. -- The Edge Cookie and the pull-sync marker are read through `handle_request_cookies` in `crates/trusted-server-core/src/cookies.rs`, whose jar keeps the last pair of a name. The `cookie` crate's `CookieJar::add_original` replaces an entry of the same name (`cookie-0.18.1`, `src/jar.rs` line 153, keyed by name in `src/delta.rs`). On an addressed request the jar now keeps the first pair of each name, so a cookie under the publisher's path wins over a root one left from before. A request with no prefix keeps the last, as today. -- `sharedId` is read in core only to ask whether it is present, in `publisher.rs`, where order does not matter. Its value is read by the Fastly adapter's `extract_cookie_value`, which returns the first pair, as `RequestInfo::cookie` does. +**Reading the cookie a publisher's path holds.** A browser that holds two cookies of one name, one under `/` (a root cookie) and one under `/publisher-a.example`, sends both on a request under `/publisher-a.example/`. RFC 6265 section 5.4 step 2 says a browser SHOULD list the cookie with the longer path first, and adds a note that not all browsers sort the list. The reads below rely on that order. A browser that does not sort may list the older root cookie first, and the request then reads the root cookie in place of the one under the publisher's path. -Consent cookies are written by the publisher's consent tool in the browser, not by core. A deployment that serves several publishers from one host configures each publisher's consent tool to write its cookies under that publisher's path. Core reads what the browser sends and keeps no list of which tool writes which cookie. +- The consent cookies are read through `RequestInfo::cookie` in `crates/trusted-server-core/src/evidence.rs`, which returns the first pair of that name in the `Cookie` header. The permission signal modules read `euconsent-v2`, `__gpp`, `__gpp_sid`, `us_privacy` and `__mtm_pref` through it. A consent cookie written under a publisher's path therefore already wins over a root one, on an addressed request or any other, and this change leaves that read as it is. +- The Edge Cookie and the pull-sync marker are read through `handle_request_cookies` in `crates/trusted-server-core/src/cookies.rs`, whose jar keeps the last pair of a name. The `cookie` crate's `CookieJar::add_original` replaces an entry of the same name (`cookie-0.18.1`, `src/jar.rs` line 153, keyed by name in `src/delta.rs`). On an addressed request the jar now keeps the first pair of each name, so a cookie under the publisher's path wins over a root one left from before. A request with no prefix keeps the last, as today. A request whose removed prefix is empty, or is `/`, is still an addressed request, so its jar keeps the first pair while its cookies are written under `/`. +- `sharedId` is written by the page's SharedID module. Core asks only whether the cookie is present, in `publisher.rs`, where order does not matter. The Fastly adapter reads its value with `extract_cookie_value`, which returns the first pair as `RequestInfo::cookie` does, and passes the value to `ec_finalize_response`, which uses it to collect partner identifiers. -**The `Cookie` header sent to the origin.** With every core cookie under its publisher's path, and each consent tool configured the same way, the browser never sends a cookie held under another publisher's path on this publisher's request. Such a cookie cannot reach this publisher's origin, so `rewrite_origin_request` still forwards the header as the browser sent it. Cookies written under `/` remain shared, and core cannot tell which publisher wrote one, so it forwards them to every origin on the host. This is the remaining limit, stated in section 3. +Consent cookies are written by a consent tool in the browser, not by core, and each keeps the `Path` its tool gave it. A tool that lets the deployment choose the path can be set to a publisher's path, and core has no way to check that the tool was set that way. The client script's Osano mirror writes `us_privacy`, `__gpp`, `__gpp_sid` and `euconsent-v2`, and its Sourcepoint mirror writes `__gpp` and `__gpp_sid`, each with `Path=/` fixed in `integrations/osano/index.ts` and `integrations/sourcepoint/index.ts`, so no setting can move those cookies. Core reads what the browser sends and keeps no list of which tool writes which cookie. -**Why core writes the prefix into these cookies.** The request addressing spec leaves writing the prefix into a response to a deployment's middleware. Core is the only writer of its own cookies, and a middleware that rewrote their `Path` would have to recognise core's cookies by name in every response. The prefix is written only into the `Path` of the cookies core itself writes. Links in pages and cookies an origin writes remain a middleware's job. +**The `Cookie` header sent to the origin.** A browser sends a cookie only on requests under its path, so a cookie core wrote under one publisher's path is not in the header of a request under another's, and `rewrite_origin_request` still forwards the header as the browser sent it. An origin whose requests are signed receives no `Cookie` header at all (`READER_HEADERS_NOT_SIGNED` in `publisher.rs`). Cookies under `/` still reach every origin that receives the header. They are the cookies core wrote before this change, the consent cookies the Osano and Sourcepoint mirrors write, and any cookie a page, a module or an origin writes under `/`. Core cannot tell which publisher wrote one, so it forwards them all. This is the remaining limit, stated in section 3. -**Each adapter.** The extension is set in one place for all four adapters, so none changes. +**Why core writes the prefix into these cookies.** The request addressing spec leaves writing the prefix into a response to a deployment's middleware. Core is the only writer of its own cookies, and a middleware that rewrote their `Path` would have to recognise core's cookies by name in every response. The prefix is written only into the `Path` of the cookies core itself writes. Links in pages and cookies an origin or a module writes remain a middleware's job. -| Adapter | Where the request gets its prefix | Change in the adapter | -| ---------- | ------------------------------------------------------------------ | ----------------------------------------- | -| Fastly | The entry point inserts `RemovedPathPrefix` after asking the hook | None, see section 3 for the tester routes | -| Axum | `addressed_router` in core | None | -| Cloudflare | `addressed_router` in core | None | -| Spin | `addressed_router` in core | None | +**Each adapter.** The request gets its `RemovedPathPrefix` in two places, being `addressed_router` in core, which the Axum, Cloudflare and Spin adapters wrap their routers with, and the Fastly entry point, which inserts the extension when it converts the request. The writers in core read that extension, so no adapter needs a change for those writers. The tester routes differ, because the Fastly adapter builds their prefix and so calls `for_request` itself. Axum, Cloudflare and Spin have no tester routes. Of the four adapters, only Fastly calls `ec_finalize_response` or routes the resolve endpoint, so Axum, Cloudflare and Spin reach none of the four writers today. -**Configuration.** None. The path comes from the request, and a deployment that addresses no request by path writes `Path=/` as today. +| Adapter | Where the request gets its prefix | Change in the adapter | +| ---------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- | +| Fastly | The entry point inserts `RemovedPathPrefix` after asking the hook | `run_named_route` builds the tester routes' prefix with `for_request` | +| Axum | `addressed_router` in core | None, and no tester routes | +| Cloudflare | `addressed_router` in core | None, and no tester routes | +| Spin | `addressed_router` in core | None, and no tester routes | -**Consent and privacy.** Keeping each publisher's cookies under its path stops one publisher's Edge Cookie, markers and consent reaching another publisher's pages and origin. A withdrawal expires the Edge Cookie under the same path it was written under, so the expiry reaches the cookie the browser holds for that publisher. A root Edge Cookie left from before this change is not expired by a request under a publisher's path, and it is no longer read there once a path-scoped one exists. +**Configuration.** None. The path comes from the request, and a deployment that addresses no request by path writes `Path=/` as today. No setting is added or changed. -**Failure handling.** A removed prefix that cannot be a cookie path is refused when the address is made, as above. A `Set-Cookie` value that fails to render is skipped with a warning, as today. +**Consent and privacy.** Writing core's own cookies under a publisher's path stops the browser sending a publisher's Edge Cookie, resolved marker, pull-sync marker and tester cookie on requests under another publisher's path, so another publisher's origin does not receive the ones written after this change. The change does not isolate consent, because core writes no consent cookie and each consent cookie keeps the `Path` its tool gave it, as above. `Path` is also no security boundary, because every publisher on the host is one origin to the browser. RFC 6265 section 8.5 says a resource from one path might be able to access cookies stored for another, and section 8.6 says the `Path` attribute gives no integrity protection, because a response can set a cookie with any `Path`. A withdrawal expires the Edge Cookie, the resolved marker and the pull-sync marker under the request's removed prefix, the path they were written under, so the expiry reaches the cookies the browser holds for that publisher. The expiry does not reach a root Edge Cookie written before this change. That cookie stays in the browser after a withdrawal and is still sent to every origin on the host that receives the `Cookie` header, while the identity graph still records the withdrawal. -**Rollout.** A request with no prefix writes `Path=/` and reads its jar as today, so a deployment that supplies no addressing hook, or one whose hook answers "by host", is unchanged. On a deployment already addressing by path, the first response after the change writes each core cookie under the publisher's path. The root cookie written before remains in the browser until it expires, and on that publisher's requests the path-scoped cookie wins because the jar keeps the first. +**Failure handling.** A removed prefix that is not a cookie path is refused by `RequestAddress::new` and kept off a cookie by `under_path`, as above, and the hook decides what the refused request becomes. A `Set-Cookie` value that fails to render is skipped with a warning, as today, except on the tester routes, which return `TrustedServerError::InvalidHeaderValue`. + +**Rollout.** A request with no prefix writes `Path=/` and reads its jar as today, so a deployment that supplies no addressing hook, or one whose hook answers "by host", is unchanged. On a deployment already addressing by path, core writes a cookie under the publisher's path only in a response where it writes that cookie at all. For the Edge Cookie that is when core creates an identifier for a visitor who has none, or replaces an orphaned one, which is an identifier whose row the identity graph has lost. A returning visitor's response writes no Edge Cookie. The root Edge Cookie and resolved marker written before this change therefore stay in that visitor's browser for up to a year (`COOKIE_MAX_AGE` in `ec/cookies.rs`), are still sent to every publisher's pages and origin on the host, and are still read, so the identity continues. Nothing moves that cookie under the publisher's path. A browser can hold a root cookie and a cookie under the publisher's path of one name, for example after core writes a new Edge Cookie for a visitor who still has the root one. The jar's first-pair rule then picks the cookie under the publisher's path, provided the browser lists the longer path first. **Tests.** In `crates/trusted-server-core`: -- `prefix.rs`: a prefix with no path or an empty one gives `/`, `for_request` takes the removed prefix, and a prefix holding `;`, a space or non-ASCII, or not starting with `/`, keeps `/`. -- `ec/cookies.rs`: the Edge Cookie, its expiry and the resolved marker carry `Path=/publisher-a.example` under that prefix, and `Path=/` without it. -- `ec/pull_sync_marker.rs` and `tester_cookie.rs`: the marker and the tester cookie carry the prefix path. -- `cookies.rs`: on an addressed request the first of two pairs of one name is read, and with no prefix the last is read, as today. -- `evidence.rs`: `RequestInfo::cookie` returns the first of two consent cookies of one name. -- `addressing.rs`: `RequestAddress::new` accepts an empty prefix or a cookie path, and refuses a removed prefix holding `;`, a space or non-ASCII, or not starting with `/`. +- In `prefix.rs`, a prefix with no path or an empty one gives `/`, `for_request` takes the removed prefix, and a prefix holding `;`, a space or non-ASCII, or not starting with `/`, keeps `/`. +- In `ec/cookies.rs`, the Edge Cookie, its expiry and the resolved marker carry `Path=/publisher-a.example` under that prefix, and `Path=/` without it. +- In `ec/pull_sync_marker.rs` and `tester_cookie.rs`, the marker and the tester cookie carry the prefix path. +- In `cookies.rs`, on an addressed request the first of two pairs of one name is read, and with no prefix the last is read, as today. +- In `evidence.rs`, `RequestInfo::cookie` returns the first of two consent cookies of one name. +- In `addressing.rs`, `RequestAddress::new` accepts an empty prefix or a cookie path, and refuses a removed prefix holding `;`, a space or non-ASCII, or not starting with `/`. + +In `crates/trusted-server-adapter-fastly`, `app.rs` holds `dispatch_set_tester_writes_under_the_removed_prefix`, which sends `/_ts/set-tester` through the router with a removed prefix and checks the cookie's path. The clear route has no addressed test of its own. + +Two pieces of production code build the prefix the writers use, being `for_request` in `EcContext::read_from_request` (`ec/mod.rs`) and the use of the context's prefix in `handle_ec_resolve` (`ec/resolve.rs`). No listed test fails if either is removed, because no test sends a request with a removed prefix through either. No test covers an empty removed prefix reading the jar. + +**Open questions.** Each of these waits for a decision and for a code change, and this revision changes no code. + +1. Whether `RequestAddress::new` keeps refusing a removed prefix that is not a cookie path, or leaves the check to `under_path`, which alone stops a cookie attribute being injected. The refusal is what changes the answer for `/a;b/news`. If `new` keeps the refusal, `AddressingError::InvalidPath` and its message have to name the removed prefix as well as the continuing path. +2. Whether `handle_request_cookies` treats an empty or `/` removed prefix as no prefix, so that a request whose cookies are written under `/` also reads the last pair of a name, as a request with no prefix does. A test would check it. +3. Whether to add a test that sends a request with a removed prefix through `EcContext::read_from_request` and `ec_finalize_response`, and one that does so through `handle_ec_resolve`, so that removing either piece of code that builds the writers' prefix fails a test. +4. Whether a withdrawal also expires a cookie of the same name under `/`, beside the expiry under the publisher's path, so that the withdrawal reaches an Edge Cookie written before this change. A cookie under `/` is one cookie for every publisher that uses its name, so expiring it affects all of them. +5. Whether to correct, with the code, the comments and pages this change makes untrue. The module comment of `addressing.rs`, the request addressing spec (held with every other design document of the stack in IABTechLab/trusted-server#1342) and the section on addressing in `docs/guide/configuration.md` say that core never writes the removed prefix into a response and that publishers on one host share cookies. The description of the Edge Cookie in `docs/guide/edge-cookies.md` says `Path=/`. The doc of `is_cookie_path` has absorbed the doc of `is_continuing_path`, and the docs of `PublicPrefix` and `EcContext::public_prefix` describe names and not the path. ## 3. What is not in this change -- **Cookies under `/`.** A cookie written under `/`, by an origin, by a tool not configured for the publisher's path, or by core before this change, is sent under every publisher's path and forwarded to every origin on the host. Core cannot tell which publisher wrote it. A deployment that needs an origin's cookies kept apart rewrites their `Path` with a middleware of its own, as it puts the prefix into links. -- **The client script's endpoints and cookies.** The script calls root-relative endpoints, `/auction` in `crates/trusted-server-js/lib/src/core/request.ts`, `/_ts/api/v1/ec/resolve` in `integrations/ec_client_fixed`, `/_ts/page-bids` in `integrations/gpt`, and the auction endpoint default in `integrations/prebid`. Several integrations also write cookies with `Path=/` from the page, in `integrations/prebid`, `integrations/osano` and `integrations/sourcepoint`. On a host addressed by path these requests are addressed by host and those cookies are shared. The fix is for the server to tell the script the removed prefix in the page, as it tells it the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each endpoint and use it as each cookie's path. That change touches the script's core and four integrations and is left for a change of its own. -- **Expiring root cookies left from before.** Core expires a cookie under the path of the request it answers, so a root cookie written before this change stays until its `Max-Age` ends. +- **Cookies under `/`.** A cookie written under `/`, by an origin, by a module, by a consent tool not set to the publisher's path, or by core before this change, is sent under every publisher's path and forwarded to every origin on the host that is not signed. Core cannot tell which publisher wrote it. A deployment that needs an origin's cookies kept apart rewrites their `Path` with a middleware of its own, as it puts the prefix into links. +- **Cookies a module or an integration writes.** A module's own `Set-Cookie` headers pass through core unchanged unless they name a cookie under the public prefix (`reserved_response_effect` in `ec/module.rs`), so their `Path` is the module's choice. The GPT diagnostics cookie `__Host-ts-console`, which `crates/ad-tag/google/src/diagnostics.rs` writes, has to stay `Path=/`, because browsers accept a cookie with the `__Host-` prefix only with `Path=/` (`draft-ietf-httpbis-rfc6265bis`, section 4.1.3.2). +- **Nested prefixes.** If one publisher's prefix lies inside another's, such as `/news` and `/news/sport`, the cookies under the outer prefix are also sent on requests under the inner one. +- **The client script's endpoints and cookies.** The script calls root-relative endpoints, `/auction` in `crates/trusted-server-js/lib/src/core/request.ts`, `/_ts/api/v1/ec/resolve` in `integrations/ec_client_fixed`, `/_ts/page-bids` in `integrations/gpt`, and the auction endpoint default in `integrations/prebid`. Several integrations also write cookies with `Path=/` from the page, being the eids cookie in `integrations/prebid` and the consent cookies in `integrations/osano` and `integrations/sourcepoint`. On a host addressed by path these requests are addressed by host, so the browser no longer sends them the cookies core wrote under the publisher's path, and the cookies core writes in answer to them, such as the resolve endpoint's, take `Path=/`. The cookies the page writes stay shared. The fix is for the server to tell the script the removed prefix in the page, as the server tells the script the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each endpoint and use it as each cookie's path. That change touches the script's core and five integrations, being `ec_client_fixed`, `gpt`, `osano`, `prebid` and `sourcepoint`, and is left for a change of its own. +- **Expiring root cookies left from before.** Core expires a cookie under the path of the request it answers, so a root Edge Cookie or resolved marker written before this change stays until its `Max-Age` ends, and a withdrawal does not remove it. - **Partitioning other first-party storage.** Local storage and other storage the page script keeps belong to the host, not the path. ## 4. Acceptance -The tests listed under "Tests" in section 2 pass. `cargo test -p trusted-server-core`, `cargo clippy -p trusted-server-core --all-targets -- -D warnings` and `cargo fmt --all -- --check` pass. With no `RemovedPathPrefix` on a request, every `Set-Cookie` core writes is byte for byte as before, which the existing cookie tests check unchanged. +The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester_writes_under_the_removed_prefix` in `crates/trusted-server-adapter-fastly/src/app.rs`, which `cargo test-fastly` runs. `cargo test -p trusted-server-core`, `cargo test-fastly`, `cargo clippy -p trusted-server-core --all-targets -- -D warnings`, `cargo clippy-fastly` and `cargo fmt --all -- --check` pass. With no `RemovedPathPrefix` on a request, every `Set-Cookie` core writes is as before. The existing tests check the unchanged output byte for byte for the Edge Cookie and its expiry, the pull-sync marker and its expiry, and the tester cookie and its clearing, and check the resolved marker and its expiry for their `Path` only. The tests that Open questions names join this acceptance when they are written. ## Review log -No review rounds yet. +1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/50-cookies-by-path` at `185557ad2`. Issues found, five of them blocking. The five were the 421 answer for a refused removed prefix, which no adapter gives, the claim that the first response moves visitors' cookies under the publisher's path, an empty removed prefix that writes `Path=/` yet reads the first of two cookies of one name, text the Fastly tester change left stale, and tests that never reach the code that builds the prefix in production. This revision states what the code does today in each case, adds the consent tools that write `Path=/`, the signed origins, the module cookies and the nested prefixes the round found, and leaves the code changes the round asks for under Open questions. ## Revision record -| Date | Change | -| ---------- | ------------ | -| 2026-10-15 | First draft. | +| Date | Change | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-15 | First draft. | +| 2026-10-15 | Fixes from review round 1. What a refused removed prefix becomes, the rollout for returning visitors, the jar rule for an empty prefix, the adapter table, tests and acceptance for the Fastly tester change, the code no test reaches, the consent tools that write `Path=/`, signed origins, the cookies left out, the open questions and the review log. | From a2d7437d4d9a4e558920a16a722238b69c1baf65 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:07:45 +0100 Subject: [PATCH 08/21] The cookies by path design takes its round 2 corrections 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. --- .../specs/2026-10-15-cookies-by-path-design.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md index f083e77bfa..c9f7cbc483 100644 --- a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -94,22 +94,28 @@ Consent cookies are written by a consent tool in the browser, not by core, and e In `crates/trusted-server-adapter-fastly`, `app.rs` holds `dispatch_set_tester_writes_under_the_removed_prefix`, which sends `/_ts/set-tester` through the router with a removed prefix and checks the cookie's path. The clear route has no addressed test of its own. -Two pieces of production code build the prefix the writers use, being `for_request` in `EcContext::read_from_request` (`ec/mod.rs`) and the use of the context's prefix in `handle_ec_resolve` (`ec/resolve.rs`). No listed test fails if either is removed, because no test sends a request with a removed prefix through either. No test covers an empty removed prefix reading the jar. +Two pieces of production code build the prefix the writers use, being `for_request` in `read_from_request_with_geo_status` (`ec/mod.rs`), which all three `read_from_request*` functions of `EcContext` reach, and the use of the context's prefix in `handle_ec_resolve` (`ec/resolve.rs`). No test checks a cookie written under a removed prefix through either, so no listed test fails if either is removed. Two tests do pass a removed prefix through a context reader. In `publisher.rs`, `a_source_answering_per_request_sends_each_request_to_its_own_origin` sends `/a.example` and `/b.example` through `EcContext::read_from_request`, by way of `run_publisher_proxy_with`, and asserts only the origin each request goes to. The Fastly test above reaches `read_from_request_resolving_geo`, because the adapter reads the Edge Cookie context before it runs a tester route, and checks only the tester cookie, whose prefix `run_named_route` builds apart from the context. No test sends a request with a removed prefix through `handle_ec_resolve`, and none covers an empty removed prefix reading the jar. **Open questions.** Each of these waits for a decision and for a code change, and this revision changes no code. 1. Whether `RequestAddress::new` keeps refusing a removed prefix that is not a cookie path, or leaves the check to `under_path`, which alone stops a cookie attribute being injected. The refusal is what changes the answer for `/a;b/news`. If `new` keeps the refusal, `AddressingError::InvalidPath` and its message have to name the removed prefix as well as the continuing path. 2. Whether `handle_request_cookies` treats an empty or `/` removed prefix as no prefix, so that a request whose cookies are written under `/` also reads the last pair of a name, as a request with no prefix does. A test would check it. -3. Whether to add a test that sends a request with a removed prefix through `EcContext::read_from_request` and `ec_finalize_response`, and one that does so through `handle_ec_resolve`, so that removing either piece of code that builds the writers' prefix fails a test. +3. Whether to add a test that sends a request with a removed prefix through `EcContext::read_from_request` and `ec_finalize_response` and checks the path of the cookies written, and one that does the same through `handle_ec_resolve`, so that removing either piece of code that builds the writers' prefix fails a test. 4. Whether a withdrawal also expires a cookie of the same name under `/`, beside the expiry under the publisher's path, so that the withdrawal reaches an Edge Cookie written before this change. A cookie under `/` is one cookie for every publisher that uses its name, so expiring it affects all of them. -5. Whether to correct, with the code, the comments and pages this change makes untrue. The module comment of `addressing.rs`, the request addressing spec (held with every other design document of the stack in IABTechLab/trusted-server#1342) and the section on addressing in `docs/guide/configuration.md` say that core never writes the removed prefix into a response and that publishers on one host share cookies. The description of the Edge Cookie in `docs/guide/edge-cookies.md` says `Path=/`. The doc of `is_cookie_path` has absorbed the doc of `is_continuing_path`, and the docs of `PublicPrefix` and `EcContext::public_prefix` describe names and not the path. +5. Whether to correct, with the code, the comments and pages this change makes untrue. + - The module comment of `addressing.rs`, the request addressing spec (held with every other design document of the stack in IABTechLab/trusted-server#1342) and the section on addressing in `docs/guide/configuration.md` say that core never writes the removed prefix into a response and that publishers on one host share cookies. + - The request addressing spec also says that this design "keeps those cookies with the publisher", after naming the consent cookies, `sharedId` and the `Cookie` header forwarded to the origin. This design does not isolate the consent cookies or `sharedId`, and the forwarded header still carries every cookie under `/`. + - The description of the Edge Cookie in `docs/guide/edge-cookies.md` says `Path=/`. + - The rustdoc of `PublicPrefix::for_request` says that publishers served from one host by path "never receive each other's cookies", which holds only for cookies written under a publisher's path, such as the ones core writes after this change. + - The doc of `parse_cookies_to_jar_keeping_first` in `cookies.rs` states as a fact that a browser lists the cookie with the longer `Path` first, where RFC 6265 section 5.4 step 2 makes it a SHOULD. + - The doc of `is_cookie_path` has absorbed the doc of `is_continuing_path`, and the docs of `PublicPrefix` and `EcContext::public_prefix` describe names and not the path. ## 3. What is not in this change - **Cookies under `/`.** A cookie written under `/`, by an origin, by a module, by a consent tool not set to the publisher's path, or by core before this change, is sent under every publisher's path and forwarded to every origin on the host that is not signed. Core cannot tell which publisher wrote it. A deployment that needs an origin's cookies kept apart rewrites their `Path` with a middleware of its own, as it puts the prefix into links. - **Cookies a module or an integration writes.** A module's own `Set-Cookie` headers pass through core unchanged unless they name a cookie under the public prefix (`reserved_response_effect` in `ec/module.rs`), so their `Path` is the module's choice. The GPT diagnostics cookie `__Host-ts-console`, which `crates/ad-tag/google/src/diagnostics.rs` writes, has to stay `Path=/`, because browsers accept a cookie with the `__Host-` prefix only with `Path=/` (`draft-ietf-httpbis-rfc6265bis`, section 4.1.3.2). - **Nested prefixes.** If one publisher's prefix lies inside another's, such as `/news` and `/news/sport`, the cookies under the outer prefix are also sent on requests under the inner one. -- **The client script's endpoints and cookies.** The script calls root-relative endpoints, `/auction` in `crates/trusted-server-js/lib/src/core/request.ts`, `/_ts/api/v1/ec/resolve` in `integrations/ec_client_fixed`, `/_ts/page-bids` in `integrations/gpt`, and the auction endpoint default in `integrations/prebid`. Several integrations also write cookies with `Path=/` from the page, being the eids cookie in `integrations/prebid` and the consent cookies in `integrations/osano` and `integrations/sourcepoint`. On a host addressed by path these requests are addressed by host, so the browser no longer sends them the cookies core wrote under the publisher's path, and the cookies core writes in answer to them, such as the resolve endpoint's, take `Path=/`. The cookies the page writes stay shared. The fix is for the server to tell the script the removed prefix in the page, as the server tells the script the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each endpoint and use it as each cookie's path. That change touches the script's core and five integrations, being `ec_client_fixed`, `gpt`, `osano`, `prebid` and `sourcepoint`, and is left for a change of its own. +- **The client script's paths and cookies.** The script calls paths at the root of the page's host, which carry no publisher part. Its core calls `/auction` (`crates/trusted-server-js/lib/src/core/request.ts`). Of its fourteen integrations, `ec_client_fixed` posts to `/_ts/api/v1/ec/resolve`, `gpt` fetches `/_ts/page-bids` and rewrites its SDK to `/integrations/gpt`, `prebid` defaults its auction endpoint to `/auction`, `creative` calls `/first-party/sign` and `/first-party/proxy-rebuild`, and `aps`, `datadome`, `didomi`, `google_tag_manager`, `lockr`, `permutive` and `sourcepoint` aim their SDK, renderer or API paths at `/integrations//` on the page's origin. Three integrations also write cookies with `Path=/` from the page, being the eids cookie in `prebid` and the consent cookies in `osano` and `sourcepoint`. On a host addressed by path these requests are addressed by host, so the browser no longer sends them the cookies core wrote under the publisher's path, and the cookies core writes in answer to them, such as the resolve endpoint's, take `Path=/`. The cookies the page writes stay shared. The fix is for the server to tell the script the removed prefix in the page, as the server tells the script the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each path and use it as each cookie's path. That change touches the script's core and twelve of its fourteen integrations, being `aps`, `creative`, `datadome`, `didomi`, `ec_client_fixed`, `google_tag_manager`, `gpt`, `lockr`, `osano`, `permutive`, `prebid` and `sourcepoint`, and is left for a change of its own. - **Expiring root cookies left from before.** Core expires a cookie under the path of the request it answers, so a root Edge Cookie or resolved marker written before this change stays until its `Max-Age` ends, and a withdrawal does not remove it. - **Partitioning other first-party storage.** Local storage and other storage the page script keeps belong to the host, not the path. @@ -120,6 +126,7 @@ The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester ## Review log 1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/50-cookies-by-path` at `185557ad2`. Issues found, five of them blocking. The five were the 421 answer for a refused removed prefix, which no adapter gives, the claim that the first response moves visitors' cookies under the publisher's path, an empty removed prefix that writes `Path=/` yet reads the first of two cookies of one name, text the Fastly tester change left stale, and tests that never reach the code that builds the prefix in production. This revision states what the code does today in each case, adds the consent tools that write `Path=/`, the signed origins, the module cookies and the nested prefixes the round found, and leaves the code changes the round asks for under Open questions. +2. **Round 2, 2026-10-15.** A lighter review in a fresh session, against this page at `963bf8bd3`, of the round 1 fixes and the new text. No blocking issues. This revision corrects the claim that no test passes a removed prefix through the `EcContext` readers, names the twelve integrations of the client script that use root-relative paths or write cookies from the page where the text named five, and adds three more untrue statements to the list under Open questions. ## Revision record @@ -127,3 +134,4 @@ The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-10-15 | First draft. | | 2026-10-15 | Fixes from review round 1. What a refused removed prefix becomes, the rollout for returning visitors, the jar rule for an empty prefix, the adapter table, tests and acceptance for the Fastly tester change, the code no test reaches, the consent tools that write `Path=/`, signed origins, the cookies left out, the open questions and the review log. | +| 2026-10-15 | Corrections from review round 2. The tests that pass a removed prefix through the `EcContext` readers, the twelve integrations of the client script that use root-relative paths or write cookies, three more untrue statements under Open questions and the review log. | From a0d1d00df6d8f99f807c8cc7714cdfc691af3bf9 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:48:04 +0100 Subject: [PATCH 09/21] A removed prefix that is not a cookie path addresses the request as before 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. --- crates/trusted-server-core/src/addressing.rs | 89 +++++++++++++------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/crates/trusted-server-core/src/addressing.rs b/crates/trusted-server-core/src/addressing.rs index f51a68592a..2de122e4fa 100644 --- a/crates/trusted-server-core/src/addressing.rs +++ b/crates/trusted-server-core/src/addressing.rs @@ -10,8 +10,10 @@ //! //! The removed prefix travels on the request as a [`RemovedPathPrefix`] //! extension, where a module's request preparer or a handler reads it. Core -//! never writes it into a response. A deployment whose pages must carry the -//! prefix puts it back with a middleware of its own. +//! writes it into a response only as the `Path` of the cookies core writes, +//! see [`PublicPrefix::for_request`](crate::prefix::PublicPrefix::for_request). +//! A deployment whose pages must carry the prefix, in their links for +//! example, puts it back with a middleware of its own. use std::sync::Arc; @@ -93,23 +95,21 @@ impl RequestAddress { /// An address that names `selector_values` in order, continues the /// request at `path` and records `removed_prefix` as the prefix removed. /// - /// The request's query is kept, so `path` carries none. + /// The request's query is kept, so `path` carries none. Any + /// `removed_prefix` is accepted, and where it cannot be a cookie's + /// `Path`, core writes the request's cookies under `/`. /// /// # Errors /// /// Returns [`AddressingError::InvalidPath`] when `path` does not start /// with `/`, is not percent-encoded ASCII, carries a `?` or a `#`, or is - /// not a valid URI path, or when `removed_prefix` is neither empty nor a - /// cookie path, as [`is_cookie_path`] says, because core writes it as - /// the `Path` of its cookies. + /// not a valid URI path. pub fn new( selector_values: Vec, path: String, removed_prefix: String, ) -> Result> { - if !is_continuing_path(&path) - || !(removed_prefix.is_empty() || is_cookie_path(&removed_prefix)) - { + if !is_continuing_path(&path) { return Err(Report::new(AddressingError::InvalidPath)); } Ok(Self { @@ -176,9 +176,10 @@ impl RequestAddressing for ByHostAddressing { /// The path prefix a [`RequestAddressing`] removed from a request, carried /// as a request extension. /// -/// Present only on a request a hook addressed. Putting the prefix back into -/// a response, such as into the links of a page, is the deployment's job, -/// done by a middleware of its own. +/// Present only on a request a hook addressed. Core writes its own cookies +/// under the prefix. Putting the prefix back into the rest of a response, +/// such as into the links of a page, is the deployment's job, done by a +/// middleware of its own. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemovedPathPrefix(String); @@ -280,9 +281,6 @@ fn addressed_handler( } } -/// Whether `path` is one a request can continue with, which is a -/// percent-encoded ASCII path as a request carries it, so every adapter -/// routes the same path. /// Whether `path` can be the `Path` attribute of a cookie. /// /// It starts with `/` and holds only visible ASCII other than `;`, so @@ -296,6 +294,9 @@ pub fn is_cookie_path(path: &str) -> bool { .all(|byte| byte.is_ascii_graphic() && byte != b';') } +/// Whether `path` is one a request can continue with, which is a +/// percent-encoded ASCII path as a request carries it, so every adapter +/// routes the same path. fn is_continuing_path(path: &str) -> bool { path.starts_with('/') && path.is_ascii() @@ -431,26 +432,50 @@ mod tests { } } - #[test] - fn a_removed_prefix_is_empty_or_a_cookie_path() { - for prefix in ["", "/publisher-a.example", "/a%3Bb"] { - assert!( - RequestAddress::new(Vec::new(), "/news".to_owned(), prefix.to_owned()).is_ok(), - "should accept `{prefix}`" - ); - } - for prefix in [ - "publisher-a.example", - "/a;Domain=example.com", - "/a b", - "/\u{e9}", - ] { - let error = RequestAddress::new(Vec::new(), "/news".to_owned(), prefix.to_owned()) - .expect_err("should refuse a prefix that cannot be a cookie path"); - assert_eq!(*error.current_context(), AddressingError::InvalidPath); + /// The hook in this module's documentation, which addresses a request + /// by host when [`RequestAddress::new`] refuses it. + struct DocumentedHook; + + impl RequestAddressing for DocumentedHook { + fn address(&self, _host: Option<&str>, path: &str) -> Addressing { + let rest = path.strip_prefix('/').unwrap_or(path); + let (segment, tail) = rest.split_once('/').unwrap_or((rest, "")); + RequestAddress::new( + vec![segment.to_owned()], + format!("/{tail}"), + format!("/{segment}"), + ) + .map_or(Addressing::ByHost, Addressing::Addressed) } } + /// A removed prefix that cannot be a cookie's `Path` still addresses the + /// request, so an adapter that chooses a blob by the selector values + /// answers it as it answers any value with no blob. Core's cookies for + /// that request go under `/`. + #[test] + fn a_prefix_that_is_not_a_cookie_path_still_addresses_the_request() { + let Addressing::Addressed(address) = + DocumentedHook.address(Some("front.example"), "/a;b/news") + else { + panic!("should address `/a;b/news` by its first segment"); + }; + assert_eq!(address.selector_values(), ["a;b"]); + assert_eq!(address.path(), "/news"); + assert_eq!(address.removed_prefix(), "/a;b"); + + let mut addressed = request("https://front.example/a;b/news", Some("front.example")); + address_request(&DocumentedHook, &mut addressed).expect("should address the request"); + assert_eq!(addressed.uri().path(), "/news"); + assert_eq!( + crate::prefix::PublicPrefix::default() + .for_request(&addressed) + .cookie_path(), + "/", + "a prefix holding `;` should never reach a cookie's `Path`" + ); + } + #[test] fn the_error_carries_nothing_from_the_request() { let error = RequestAddress::new(Vec::new(), "/private?id=reader".to_owned(), String::new()) From 25c673f3d956c778af1bb62819b3c8c9177ac6fa Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:48:53 +0100 Subject: [PATCH 10/21] An empty or root removed prefix reads the cookie jar as no prefix does 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. --- crates/trusted-server-core/src/addressing.rs | 11 +++++ crates/trusted-server-core/src/cookies.rs | 49 +++++++++++++++----- crates/trusted-server-core/src/prefix.rs | 29 ++++++------ 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/crates/trusted-server-core/src/addressing.rs b/crates/trusted-server-core/src/addressing.rs index 2de122e4fa..5972c3e7ad 100644 --- a/crates/trusted-server-core/src/addressing.rs +++ b/crates/trusted-server-core/src/addressing.rs @@ -294,6 +294,17 @@ pub fn is_cookie_path(path: &str) -> bool { .all(|byte| byte.is_ascii_graphic() && byte != b';') } +/// The `Path` core writes its cookies under on a request whose removed +/// prefix is `prefix`, or `None` for `/`. +/// +/// An empty prefix, `/` itself and a prefix that cannot be a cookie's `Path` +/// all give `None`, so such a request writes and reads its cookies as a +/// request with no removed prefix does. +#[must_use] +pub(crate) fn cookie_path_for(prefix: &str) -> Option<&str> { + (prefix != "/" && is_cookie_path(prefix)).then_some(prefix) +} + /// Whether `path` is one a request can continue with, which is a /// percent-encoded ASCII path as a request carries it, so every adapter /// routes the same path. diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index 5c603ce8c1..67ee0cc94e 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -32,9 +32,11 @@ pub fn parse_cookies_to_jar(s: &str) -> CookieJar { /// Parses a cookie string into a [`CookieJar`] that keeps the first cookie /// of each name. /// -/// A browser lists the cookie with the longer `Path` first, so on a request -/// under a publisher's path this keeps the publisher's cookie over one of -/// the same name written under `/`. +/// RFC 6265 section 5.4 says a browser SHOULD list the cookie with the +/// longer `Path` first. In a browser that does, on a request under a +/// publisher's path this keeps the publisher's cookie over one of the same +/// name written under `/`. A browser that does not may list the root cookie +/// first, and the root cookie is then the one kept. fn parse_cookies_to_jar_keeping_first(s: &str) -> CookieJar { let mut jar = CookieJar::new(); for cookie in Cookie::split_parse(s.trim().to_owned()).filter_map(Result::ok) { @@ -48,10 +50,12 @@ fn parse_cookies_to_jar_keeping_first(s: &str) -> CookieJar { /// Extracts and parses cookies from an HTTP request. /// /// Attempts to parse the Cookie header into a [`CookieJar`] for easy access -/// to individual cookies. On a request request addressing gave a removed -/// prefix, the first cookie of each name is kept, the one under the -/// publisher's path. Otherwise the last is kept, as [`parse_cookies_to_jar`] -/// keeps it. +/// to individual cookies. Where core writes its cookies under the prefix +/// request addressing removed, the first cookie of each name is kept, which +/// is the one under the publisher's path in a browser that lists longer +/// paths first. Otherwise the last is kept, as [`parse_cookies_to_jar`] +/// keeps it, which covers a request with no removed prefix and one whose +/// removed prefix is empty, `/` or not a cookie path. /// /// # Errors /// @@ -66,11 +70,12 @@ pub fn handle_request_cookies( .change_context(TrustedServerError::InvalidHeaderValue { message: "Cookie header contains invalid UTF-8".to_owned(), })?; - let addressed = req + let under_removed_prefix = req .extensions() .get::() + .and_then(|prefix| crate::addressing::cookie_path_for(prefix.as_str())) .is_some(); - let jar = if addressed { + let jar = if under_removed_prefix { parse_cookies_to_jar_keeping_first(header_value_str) } else { parse_cookies_to_jar(header_value_str) @@ -155,8 +160,9 @@ mod tests { assert_eq!(jar.get("c1").expect("should have cookie c1").value(), "v2"); } - /// A browser lists the cookie with the longer path first, so on an - /// addressed request the publisher's own cookie is the one read. + /// A browser that lists the cookie with the longer path first sends the + /// publisher's own cookie first, so on an addressed request that cookie + /// is the one read. #[test] fn an_addressed_request_reads_the_first_cookie_of_a_name() { let mut req = build_request(Some("ts-ec=publisher; ts-ec=root; other=1")); @@ -182,6 +188,27 @@ mod tests { assert_eq!(jar.get("ts-ec").map(Cookie::value), Some("root")); } + /// An empty removed prefix, `/`, or a prefix that cannot be a cookie's + /// `Path` puts core's cookies under `/`, so the request reads them as a + /// request with no removed prefix does. + #[test] + fn a_removed_prefix_whose_cookies_go_under_the_root_reads_the_last_cookie_of_a_name() { + for prefix in ["", "/", "/a;b"] { + let mut req = build_request(Some("ts-ec=publisher; ts-ec=root")); + req.extensions_mut() + .insert(crate::addressing::RemovedPathPrefix::new(prefix.to_owned())); + let jar = handle_request_cookies(&req) + .expect("should parse cookies") + .expect("should have cookie jar"); + + assert_eq!( + jar.get("ts-ec").map(Cookie::value), + Some("root"), + "removed prefix `{prefix}`" + ); + } + } + #[test] fn test_parse_cookies_to_jar_empty() { let cookie_str = ""; diff --git a/crates/trusted-server-core/src/prefix.rs b/crates/trusted-server-core/src/prefix.rs index c9c2b43630..a52450dd75 100644 --- a/crates/trusted-server-core/src/prefix.rs +++ b/crates/trusted-server-core/src/prefix.rs @@ -117,21 +117,17 @@ impl PublicPrefix { /// The same prefix, its cookies written under `path`. /// - /// `None` or an empty path writes them under `/`. So does a path that - /// cannot be a cookie's `Path`, logged, so a value written into a + /// `None`, an empty path or `/` writes them under `/`. So does a path + /// that cannot be a cookie's `Path`, logged, so a value written into a /// `Set-Cookie` header can never add an attribute. #[must_use] pub fn under_path(self, path: Option<&str>) -> Self { - let cookie_path = match path { - None | Some("") => None, - Some(path) if crate::addressing::is_cookie_path(path) => Some(path.to_owned()), - Some(_) => { - log::warn!("Writing cookies under / as the removed prefix is not a cookie path"); - None - } - }; + let cookie_path = path.and_then(crate::addressing::cookie_path_for); + if cookie_path.is_none() && path.is_some_and(|path| !matches!(path, "" | "/")) { + log::warn!("Writing cookies under / as the removed prefix is not a cookie path"); + } Self { - cookie_path, + cookie_path: cookie_path.map(str::to_owned), ..self } } @@ -407,10 +403,13 @@ mod tests { #[test] fn cookies_are_under_the_root_with_no_removed_prefix() { assert_eq!(PublicPrefix::default().cookie_path(), "/"); - assert_eq!( - PublicPrefix::default().under_path(Some("")).cookie_path(), - "/" - ); + for path in ["", "/"] { + assert_eq!( + PublicPrefix::default().under_path(Some(path)), + PublicPrefix::default(), + "`{path}` should count as no removed prefix" + ); + } } #[test] From 630068d5ff9caf2030233b75769dcc3700036e02 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:49:38 +0100 Subject: [PATCH 11/21] Tests write the Edge Cookie under the removed prefix through the context 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=/. --- crates/trusted-server-core/src/ec/mod.rs | 56 ++++++++++++++ crates/trusted-server-core/src/ec/resolve.rs | 77 +++++++++++++++++--- 2 files changed, 123 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 597690660c..f59406a258 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -2515,6 +2515,62 @@ pub(crate) mod tests { ); } + /// The request `create_test_request` builds, addressed by path with + /// `/publisher-a.example` removed. + fn addressed_test_request(headers: &[(&str, &str)]) -> Request { + let mut req = create_test_request(headers); + req.extensions_mut() + .insert(crate::addressing::RemovedPathPrefix::new( + "/publisher-a.example".to_owned(), + )); + req + } + + /// The name and the `Path` of each `Set-Cookie` value in `cookies`. + fn names_and_paths(cookies: &[String]) -> Vec<(&str, &str)> { + cookies + .iter() + .map(|cookie| { + let name = cookie.split('=').next().unwrap_or_default(); + let path = cookie + .split("; ") + .find_map(|attribute| attribute.strip_prefix("Path=")) + .unwrap_or_default(); + (name, path) + }) + .collect() + } + + /// The context takes the removed prefix from the request it reads, so + /// on a request addressed by path the Edge Cookie the finalize step + /// writes is under that prefix. + #[tokio::test] + async fn finalize_writes_the_edge_cookie_under_the_removed_prefix() { + use crate::platform::test_support::noop_services_with_ec_module; + + let mut settings = create_test_settings(); + settings.ec.module = Some(EcModuleSelection::from("header_setting")); + let services = noop_services_with_ec_module(Arc::new(HeaderSettingModule { + name: "set-cookie", + value: MODULE_EVIDENCE_COOKIE, + id: Some("module-value"), + })); + let req = addressed_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + ec.generate_if_needed(&settings, Some(&graph), &services) + .await + .expect("should generate an identifier"); + + let cookies = finalized_set_cookies(&settings, &mut ec, &graph).await; + + assert!( + names_and_paths(&cookies).contains(&("ts-ec", "/publisher-a.example")), + "the Edge Cookie should be under the removed prefix, got: {cookies:?}" + ); + } + /// A cookie a module may set for itself, used to check which module /// responses keep their headers. const MODULE_EVIDENCE_COOKIE: &str = "acme-evidence=abc123; Path=/; Secure"; diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs index 507a85e5aa..90b1508478 100644 --- a/crates/trusted-server-core/src/ec/resolve.rs +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -1033,6 +1033,21 @@ mod tests { } } + /// A location whose rules set storage without a signal, so the gate is + /// open. + fn open_gate_geo() -> crate::platform::GeoInfo { + crate::platform::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } + } + /// A context read the way a composition root builds one, with the selected /// built-in module threaded through the services, at a location whose /// rules set storage without a signal so the gate is open. @@ -1046,16 +1061,7 @@ mod tests { .uri("https://test-publisher.com/") .body(EdgeBody::empty()) .expect("should build organic request"); - let geo = crate::platform::GeoInfo { - city: String::new(), - country: "US".to_owned(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, - region: Some("CA".to_owned()), - asn: None, - }; + let geo = open_gate_geo(); let ec = EcContext::read_from_request_with_geo(settings, &organic, &services, Some(&geo)) .expect("should read EC context"); assert!( @@ -1107,6 +1113,57 @@ mod tests { ); } + /// The endpoint writes under the prefix of the context read from its own + /// request, as an adapter reads it, so on a request addressed by path the + /// Edge Cookie and its marker are under the removed prefix. + #[tokio::test] + async fn resolve_writes_under_the_removed_prefix_of_an_addressed_request() { + let settings = settings_with_client_fixed(); + let module = crate::ec::module::build_reusable_module(&settings.ec, None, None) + .expect("should build the selected module") + .expect("a built-in module is reusable"); + let services = noop_services_with_ec_module(module); + let mut req = post(FIXED_WORD); + req.extensions_mut() + .insert(crate::addressing::RemovedPathPrefix::new( + "/publisher-a.example".to_owned(), + )); + let ec = EcContext::read_from_request_with_geo( + &settings, + &req, + &services, + Some(&open_gate_geo()), + ) + .expect("should read EC context"); + + let response = handle_ec_resolve(&settings, req, &ec, Some(&in_memory_graph()), &services) + .await + .expect("a valid selection should not be an error"); + + assert_eq!(response.status(), StatusCode::OK, "should set identity"); + let names_and_paths: Vec<(String, String)> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + let cookie = value.to_str().expect("should be utf-8"); + let name = cookie.split('=').next().unwrap_or_default(); + let path = cookie + .split("; ") + .find_map(|attribute| attribute.strip_prefix("Path=")) + .unwrap_or_default(); + (name.to_owned(), path.to_owned()) + }) + .collect(); + assert_eq!( + names_and_paths, + [ + ("ts-ec".to_owned(), "/publisher-a.example".to_owned()), + ("ts-ecr".to_owned(), "/publisher-a.example".to_owned()), + ] + ); + } + /// A request the endpoint cannot take is refused, and sets no cookie. #[tokio::test] async fn resolve_refuses_a_request_it_cannot_take() { From 174a32a341ebb883f5bcec89da104fc438ea9aa2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:50:19 +0100 Subject: [PATCH 12/21] A withdrawal also expires the Edge Cookie and its marker under the root 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. --- crates/trusted-server-core/src/ec/cookies.rs | 75 ++++++++++++++++++-- crates/trusted-server-core/src/ec/mod.rs | 41 +++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index 2134704eff..bdc31be026 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -7,7 +7,8 @@ //! - `Domain` is computed as `.{publisher.domain}` for subdomain coverage //! - `Path` is the prefix's cookie path, `/` unless request addressing //! removed a prefix, so publishers served from one host by path keep -//! their cookies apart +//! these cookies apart. An expiry under a removed prefix also expires +//! the cookies of the same names under `/` //! - `Secure` restricts to HTTPS //! - `SameSite=Lax` provides CSRF protection while allowing top-level navigations //! - `Max-Age` of 1 year (or 0 to expire) @@ -118,14 +119,30 @@ pub fn set_ec_cookie( } } -/// Expires the EC cookie by setting `Max-Age=0`. +/// Expires the EC cookie and its resolved marker by setting `Max-Age=0`. /// -/// Used when a user revokes consent — the browser will delete the cookie -/// on receipt of this header. +/// Used when a user withdraws consent, and the browser deletes the cookies +/// on receipt of these headers. Where `public` writes under a removed +/// prefix, the cookies of the same names under `/` are expired too, so a +/// withdrawal also reaches the copy the browser sends on every path of the +/// host. That copy is shared by every publisher on the host whose cookies +/// take the same names. pub fn expire_ec_cookie( settings: &Settings, public: &PublicPrefix, response: &mut Response, +) { + expire_ec_cookie_under_path(settings, public, response); + if public.cookie_path() != "/" { + expire_ec_cookie_under_path(settings, &public.clone().under_path(None), response); + } +} + +/// Expires the EC cookie and its resolved marker under `public`'s path only. +fn expire_ec_cookie_under_path( + settings: &Settings, + public: &PublicPrefix, + response: &mut Response, ) { match HeaderValue::from_str(&format_set_cookie( public, @@ -304,12 +321,58 @@ mod tests { } /// On a request addressed by path, every cookie this file writes is - /// under the removed prefix, so another publisher's pages never get it. + /// under the removed prefix, so another publisher's pages never get it, + /// and the expiry also covers the cookies of the same names under `/`. #[test] fn each_cookie_is_under_the_removed_prefix_on_an_addressed_request() { let public = PublicPrefix::default().under_path(Some("/publisher-a.example")); - assert_eq!(set_cookie_paths(&public), ["/publisher-a.example"; 4]); + assert_eq!( + set_cookie_paths(&public), + [ + "/publisher-a.example", + "/publisher-a.example", + "/publisher-a.example", + "/publisher-a.example", + "/", + "/" + ] + ); + } + + /// A withdrawal under a removed prefix expires the Edge Cookie and its + /// marker under that prefix and under `/`, each otherwise as written + /// with no prefix. + #[test] + fn an_expiry_under_a_removed_prefix_also_expires_the_root_cookies() { + let settings = create_test_settings(); + let domain = settings.publisher.ec_cookie_domain(); + let public = PublicPrefix::default().under_path(Some("/publisher-a.example")); + let mut response = empty_response(); + + expire_ec_cookie(&settings, &public, &mut response); + + let written: Vec<&str> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should be valid UTF-8")) + .collect(); + let expected: Vec = ["/publisher-a.example", "/"] + .into_iter() + .flat_map(|path| { + [ + format!( + "ts-ec=; Domain={domain}; Path={path}; Secure; SameSite=Lax; Max-Age=0; \ + HttpOnly" + ), + format!( + "ts-ecr=; Domain={domain}; Path={path}; Secure; SameSite=Lax; Max-Age=0" + ), + ] + }) + .collect(); + assert_eq!(written, expected); } #[test] diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index f59406a258..392ad61c9a 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -2571,6 +2571,47 @@ pub(crate) mod tests { ); } + /// A withdrawal on a request addressed by path expires the Edge Cookie + /// and its resolved marker under the removed prefix and under `/`, so a + /// copy the browser holds under `/` does not survive it. With no removed + /// prefix each is expired once, under `/`. + #[tokio::test] + async fn a_withdrawal_under_a_removed_prefix_also_expires_the_root_cookies() { + let mut settings = create_test_settings(); + settings.ec.module = None; + settings.ec.module_blocks.clear(); + let cookie = format!("ts-ec={}", valid_ec_id("a", "abcXYZ")); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + + for (req, expected) in [ + ( + addressed_test_request(&[("cookie", &cookie)]), + vec![ + ("ts-ec", "/publisher-a.example"), + ("ts-ecr", "/publisher-a.example"), + ("ts-ec", "/"), + ("ts-ecr", "/"), + ], + ), + ( + create_test_request(&[("cookie", &cookie)]), + vec![("ts-ec", "/"), ("ts-ecr", "/")], + ), + ] { + let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context") + .with_storage_withdrawn_for_test(true); + + let cookies = finalized_set_cookies(&settings, &mut ec, &graph).await; + + assert!( + cookies.iter().all(|cookie| cookie.contains("Max-Age=0")), + "a withdrawal should only expire cookies, got: {cookies:?}" + ); + assert_eq!(names_and_paths(&cookies), expected); + } + } + /// A cookie a module may set for itself, used to check which module /// responses keep their headers. const MODULE_EVIDENCE_COOKIE: &str = "acme-evidence=abc123; Path=/; Secure"; From ea1c26a170c91d975afa890434a8f7f0a39c535b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:50:46 +0100 Subject: [PATCH 13/21] The prefix docs say a prefix also carries the path of its cookies 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. --- crates/trusted-server-core/src/ec/mod.rs | 4 +++- crates/trusted-server-core/src/prefix.rs | 14 ++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 392ad61c9a..2e5441e69b 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -951,7 +951,9 @@ impl EcContext { } /// The start of every cookie name this request's Edge Cookie state reads - /// and writes, such as `ts` for `ts-ec`. + /// and writes, such as `ts` for `ts-ec`, and the `Path` it writes them + /// under, which is the request's removed prefix or `/`, see + /// [`PublicPrefix::for_request`]. #[must_use] pub fn public_prefix(&self) -> &PublicPrefix { &self.public_prefix diff --git a/crates/trusted-server-core/src/prefix.rs b/crates/trusted-server-core/src/prefix.rs index a52450dd75..fb6dbf937e 100644 --- a/crates/trusted-server-core/src/prefix.rs +++ b/crates/trusted-server-core/src/prefix.rs @@ -54,7 +54,8 @@ const TRUSTED_SERVER_HEAD: &str = "ts"; /// so the script writes the deployment's names and holds no copy of its own. pub const PUBLIC_PREFIX_ATTRIBUTE: &str = "data-ts-public-prefix"; -/// The start of every name the browser holds for a publisher, such as `_ex`. +/// The start of every name the browser holds for a publisher, such as `_ex`, +/// and the `Path` of the cookies it names. /// /// Made of parts, such as `_ex` and `www.example.com`, joined by `.`. Every /// part holds only ASCII letters, digits, `_`, `-` and `.`, so the prefix is a @@ -62,6 +63,9 @@ pub const PUBLIC_PREFIX_ATTRIBUTE: &str = "data-ts-public-prefix"; /// or a single `_`, so no cookie name starts with `__`, where browsers give /// names such as `__Host-` rules of their own and drop a cookie that breaks /// them. +/// +/// The path is `/` unless [`Self::for_request`] or [`Self::under_path`] +/// built the prefix for a request addressed by path. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PublicPrefix { dotted: String, @@ -133,10 +137,12 @@ impl PublicPrefix { } /// The prefix for `request`, its cookies written under the prefix - /// request addressing removed, or under `/` when none was removed. + /// request addressing removed, or under `/` when none was removed or + /// [`Self::under_path`] keeps `/` for it. /// - /// A browser sends a cookie only under its `Path`, so publishers served - /// from one host by path never receive each other's cookies. + /// A browser sends a cookie only on requests under its `Path`, so a + /// cookie written under one publisher's path is not sent on another + /// publisher's requests. A cookie written under `/` is sent on them all. #[must_use] pub fn for_request(&self, request: &http::Request) -> Self { self.clone().under_path( From 846af27ca562474f91ec4cb5f81fb96a345159ec Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:55:04 +0100 Subject: [PATCH 14/21] The cookies by path design states the code fixes for open questions 1 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. --- .../2026-10-15-cookies-by-path-design.md | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md index c9f7cbc483..db4f7942d7 100644 --- a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -21,7 +21,7 @@ Request addressing lets one host serve several publishers as `https://front.exam Browsers already keep cookies apart by path. RFC 6265 sections 5.1.4 and 5.4 send a cookie only on a request whose path matches the cookie's `Path`, which means the same path or one below it. A cookie written with `Path=/publisher-a.example` is sent under `/publisher-a.example/` and never under `/publisher-b.example/`. Core uses that rule and adds no setting. -**Every cookie core writes carries the removed prefix as its path.** An addressed request is one the request addressing hook answered with an address, so it carries a `RemovedPathPrefix`, even an empty one. A request addressed by host carries none. On an addressed request, a cookie core writes has `Path=` in place of `Path=/`. A request with no prefix, or an empty one, writes `Path=/`, exactly as today. +**Every cookie core writes carries the removed prefix as its path.** An addressed request is one the request addressing hook answered with an address, so it carries a `RemovedPathPrefix`, even an empty one. A request addressed by host carries none. On an addressed request, a cookie core writes has `Path=` in place of `Path=/`. A request with no prefix, an empty one, `/` itself or one that cannot be a cookie's `Path` writes `Path=/`, exactly as today. Every writer already takes the publisher's `PublicPrefix`, because the prefix names the cookie. The prefix now also carries the path its cookies live under, so a writer reads the name and the path from one value and cannot write one without the other. @@ -50,14 +50,14 @@ The writers, each of which now writes `Path={public.cookie_path()}`, are these. `EcContext` builds its prefix with `for_request` when it reads the request, so every cookie the finalize step writes takes the request's path. The resolve endpoint writes its cookies under that same prefix, read from the context and no longer from the runtime services. The tester routes write under the prefix their caller passes them, and only the Fastly adapter serves those routes. -**A removed prefix must be a cookie path.** A prefix is written into a `Set-Cookie` header, so `addressing::is_cookie_path` accepts only a prefix that starts with `/` and holds nothing but visible ASCII other than `;`. The check sets no limit on length. `RequestAddress::new` returns `AddressingError::InvalidPath` for a removed prefix that fails the check, and still accepts an empty prefix, which removes nothing and writes its cookies under `/`. `under_path` applies the same check and keeps `/` for a prefix that fails it, with a warning, so a prefix built some other way cannot add an attribute to a cookie. +**Only a cookie path reaches a cookie.** A prefix is written into a `Set-Cookie` header, so `addressing::is_cookie_path` accepts only a prefix that starts with `/` and holds nothing but visible ASCII other than `;`. The check sets no limit on length. `addressing::cookie_path_for` gives the path core writes a request's cookies under, which is the removed prefix where it passes the check and is not `/`, and `/` otherwise. An empty prefix, `/` itself and a prefix that fails the check therefore all write under `/`. `under_path` applies that rule and logs a warning for a prefix that fails the check, so no prefix can add an attribute to a cookie. `RequestAddress::new` does not check the removed prefix, so `under_path` holds the only check. -**What a refused prefix becomes.** `RequestAddress::new` hands its error to the hook, and the hook decides what the request becomes. `Addressing` has only `ByHost` and `Addressed`, so a hook cannot refuse a request, and no adapter calls `new`. The hook in the rustdoc of `addressing.rs`, and the one in the request addressing spec, map the error to `ByHost`. A request such as `/a;b/news` is then addressed by its host, keeps its whole path and has no removed prefix. On a Fastly service whose key selector carries `{host}`, that request is served from the front host's own blob when the front host has one. Before this change the same request carried the selector value `a;b`, which `normalized_host` drops because `a;b` is not a host name, so no blob was found and the Fastly adapter answered `421 Misdirected Request`. That 421 is the adapter's answer to a missing blob, and no adapter answers 421 because a prefix is not a cookie path. `AddressingError::InvalidPath` still describes only the continuing path. +**What a prefix that is not a cookie path becomes.** `RequestAddress::new` accepts it, so the hook in the rustdoc of `addressing.rs`, which maps an error from `new` to `ByHost`, addresses a request such as `/a;b/news` as it did before this change, with the selector value `a;b`, the continuing path `/news` and the removed prefix `/a;b`. On a Fastly service whose key selector carries `{host}`, `normalized_host` drops `a;b` because it is not a host name, so no blob is found and the Fastly adapter answers `421 Misdirected Request`, its answer to a missing blob. On an adapter that reads one blob, the request continues at `/news`, core writes its cookies under `/` with a warning, and its jar is read as a request with no prefix reads it. `AddressingError::InvalidPath` describes only the continuing path, which is all `new` checks. **Reading the cookie a publisher's path holds.** A browser that holds two cookies of one name, one under `/` (a root cookie) and one under `/publisher-a.example`, sends both on a request under `/publisher-a.example/`. RFC 6265 section 5.4 step 2 says a browser SHOULD list the cookie with the longer path first, and adds a note that not all browsers sort the list. The reads below rely on that order. A browser that does not sort may list the older root cookie first, and the request then reads the root cookie in place of the one under the publisher's path. - The consent cookies are read through `RequestInfo::cookie` in `crates/trusted-server-core/src/evidence.rs`, which returns the first pair of that name in the `Cookie` header. The permission signal modules read `euconsent-v2`, `__gpp`, `__gpp_sid`, `us_privacy` and `__mtm_pref` through it. A consent cookie written under a publisher's path therefore already wins over a root one, on an addressed request or any other, and this change leaves that read as it is. -- The Edge Cookie and the pull-sync marker are read through `handle_request_cookies` in `crates/trusted-server-core/src/cookies.rs`, whose jar keeps the last pair of a name. The `cookie` crate's `CookieJar::add_original` replaces an entry of the same name (`cookie-0.18.1`, `src/jar.rs` line 153, keyed by name in `src/delta.rs`). On an addressed request the jar now keeps the first pair of each name, so a cookie under the publisher's path wins over a root one left from before. A request with no prefix keeps the last, as today. A request whose removed prefix is empty, or is `/`, is still an addressed request, so its jar keeps the first pair while its cookies are written under `/`. +- The Edge Cookie and the pull-sync marker are read through `handle_request_cookies` in `crates/trusted-server-core/src/cookies.rs`, whose jar keeps the last pair of a name. The `cookie` crate's `CookieJar::add_original` replaces an entry of the same name (`cookie-0.18.1`, `src/jar.rs` line 153, keyed by name in `src/delta.rs`). Where core writes its cookies under the removed prefix, the jar now keeps the first pair of each name, so a cookie under the publisher's path wins over a root one left from before. Where core writes its cookies under `/`, on a request with no prefix, an empty one, `/` itself or one that is not a cookie path, the jar keeps the last pair, as today. Both the write and the read take the path from `cookie_path_for`, so a request reads the cookies under the path it writes them under. - `sharedId` is written by the page's SharedID module. Core asks only whether the cookie is present, in `publisher.rs`, where order does not matter. The Fastly adapter reads its value with `extract_cookie_value`, which returns the first pair as `RequestInfo::cookie` does, and passes the value to `ec_finalize_response`, which uses it to collect partner identifiers. Consent cookies are written by a consent tool in the browser, not by core, and each keeps the `Path` its tool gave it. A tool that lets the deployment choose the path can be set to a publisher's path, and core has no way to check that the tool was set that way. The client script's Osano mirror writes `us_privacy`, `__gpp`, `__gpp_sid` and `euconsent-v2`, and its Sourcepoint mirror writes `__gpp` and `__gpp_sid`, each with `Path=/` fixed in `integrations/osano/index.ts` and `integrations/sourcepoint/index.ts`, so no setting can move those cookies. Core reads what the browser sends and keeps no list of which tool writes which cookie. @@ -77,38 +77,33 @@ Consent cookies are written by a consent tool in the browser, not by core, and e **Configuration.** None. The path comes from the request, and a deployment that addresses no request by path writes `Path=/` as today. No setting is added or changed. -**Consent and privacy.** Writing core's own cookies under a publisher's path stops the browser sending a publisher's Edge Cookie, resolved marker, pull-sync marker and tester cookie on requests under another publisher's path, so another publisher's origin does not receive the ones written after this change. The change does not isolate consent, because core writes no consent cookie and each consent cookie keeps the `Path` its tool gave it, as above. `Path` is also no security boundary, because every publisher on the host is one origin to the browser. RFC 6265 section 8.5 says a resource from one path might be able to access cookies stored for another, and section 8.6 says the `Path` attribute gives no integrity protection, because a response can set a cookie with any `Path`. A withdrawal expires the Edge Cookie, the resolved marker and the pull-sync marker under the request's removed prefix, the path they were written under, so the expiry reaches the cookies the browser holds for that publisher. The expiry does not reach a root Edge Cookie written before this change. That cookie stays in the browser after a withdrawal and is still sent to every origin on the host that receives the `Cookie` header, while the identity graph still records the withdrawal. +**Consent and privacy.** Writing core's own cookies under a publisher's path stops the browser sending a publisher's Edge Cookie, resolved marker, pull-sync marker and tester cookie on requests under another publisher's path, so another publisher's origin does not receive the ones written after this change. The change does not isolate consent, because core writes no consent cookie and each consent cookie keeps the `Path` its tool gave it, as above. `Path` is also no security boundary, because every publisher on the host is one origin to the browser. RFC 6265 section 8.5 says a resource from one path might be able to access cookies stored for another, and section 8.6 says the `Path` attribute gives no integrity protection, because a response can set a cookie with any `Path`. A withdrawal expires the Edge Cookie, the resolved marker and the pull-sync marker under the request's removed prefix, the path they were written under, so the expiry reaches the cookies the browser holds for that publisher. `expire_ec_cookie`, which core calls only on a withdrawal, also expires the Edge Cookie and the resolved marker under `/` when the prefix carries a path, so a root Edge Cookie written before this change does not survive the withdrawal. A cookie under `/` is one cookie for every publisher on the host that uses its name, so that expiry reaches each of them, and publishers that keep core's own public prefix, `ts`, all use the name `ts-ec`. The pull-sync marker is expired under the removed prefix only, and a marker under `/` lasts at most an hour (`MARKER_MAX_AGE_SECS` in `ec/pull_sync_marker.rs`). -**Failure handling.** A removed prefix that is not a cookie path is refused by `RequestAddress::new` and kept off a cookie by `under_path`, as above, and the hook decides what the refused request becomes. A `Set-Cookie` value that fails to render is skipped with a warning, as today, except on the tester routes, which return `TrustedServerError::InvalidHeaderValue`. +**Failure handling.** A removed prefix that is not a cookie path is kept off a cookie by `under_path`, as above, and the request is addressed as its hook answered. A `Set-Cookie` value that fails to render is skipped with a warning, as today, except on the tester routes, which return `TrustedServerError::InvalidHeaderValue`. -**Rollout.** A request with no prefix writes `Path=/` and reads its jar as today, so a deployment that supplies no addressing hook, or one whose hook answers "by host", is unchanged. On a deployment already addressing by path, core writes a cookie under the publisher's path only in a response where it writes that cookie at all. For the Edge Cookie that is when core creates an identifier for a visitor who has none, or replaces an orphaned one, which is an identifier whose row the identity graph has lost. A returning visitor's response writes no Edge Cookie. The root Edge Cookie and resolved marker written before this change therefore stay in that visitor's browser for up to a year (`COOKIE_MAX_AGE` in `ec/cookies.rs`), are still sent to every publisher's pages and origin on the host, and are still read, so the identity continues. Nothing moves that cookie under the publisher's path. A browser can hold a root cookie and a cookie under the publisher's path of one name, for example after core writes a new Edge Cookie for a visitor who still has the root one. The jar's first-pair rule then picks the cookie under the publisher's path, provided the browser lists the longer path first. +**Rollout.** A request with no prefix writes `Path=/` and reads its jar as today, so a deployment that supplies no addressing hook, or one whose hook answers "by host", is unchanged. On a deployment already addressing by path, core writes a cookie under the publisher's path only in a response where it writes that cookie at all. For the Edge Cookie that is when core creates an identifier for a visitor who has none, or replaces an orphaned one, which is an identifier whose row the identity graph has lost. A returning visitor's response writes no Edge Cookie. The root Edge Cookie and resolved marker written before this change therefore stay in that visitor's browser for up to a year (`COOKIE_MAX_AGE` in `ec/cookies.rs`), are still sent to every publisher's pages and origin on the host, and are still read, so the identity continues. Nothing moves that cookie under the publisher's path, and before its `Max-Age` ends only a withdrawal, which expires it under `/` as above, removes it. A browser can hold a root cookie and a cookie under the publisher's path of one name, for example after core writes a new Edge Cookie for a visitor who still has the root one. The jar's first-pair rule then picks the cookie under the publisher's path, provided the browser lists the longer path first. **Tests.** In `crates/trusted-server-core`: -- In `prefix.rs`, a prefix with no path or an empty one gives `/`, `for_request` takes the removed prefix, and a prefix holding `;`, a space or non-ASCII, or not starting with `/`, keeps `/`. -- In `ec/cookies.rs`, the Edge Cookie, its expiry and the resolved marker carry `Path=/publisher-a.example` under that prefix, and `Path=/` without it. +- In `prefix.rs`, a prefix with no path, an empty one or `/` gives `/` and equals the prefix with no path, `for_request` takes the removed prefix, and a prefix holding `;`, a space or non-ASCII, or not starting with `/`, keeps `/`. +- In `ec/cookies.rs`, the Edge Cookie, its expiry and the resolved marker carry `Path=/publisher-a.example` under that prefix, and `Path=/` without it. `an_expiry_under_a_removed_prefix_also_expires_the_root_cookies` checks byte for byte that an expiry under that prefix also expires the Edge Cookie and the resolved marker under `/`. - In `ec/pull_sync_marker.rs` and `tester_cookie.rs`, the marker and the tester cookie carry the prefix path. -- In `cookies.rs`, on an addressed request the first of two pairs of one name is read, and with no prefix the last is read, as today. +- In `cookies.rs`, on an addressed request the first of two pairs of one name is read, and with no prefix the last is read, as today, and so is the last with a removed prefix that is empty, `/` or `/a;b`. - In `evidence.rs`, `RequestInfo::cookie` returns the first of two consent cookies of one name. -- In `addressing.rs`, `RequestAddress::new` accepts an empty prefix or a cookie path, and refuses a removed prefix holding `;`, a space or non-ASCII, or not starting with `/`. +- In `addressing.rs`, `a_prefix_that_is_not_a_cookie_path_still_addresses_the_request` checks that the hook in the module's rustdoc addresses `/a;b/news` with the selector value `a;b`, the continuing path `/news` and the removed prefix `/a;b`, as before this change, and that core writes that request's cookies under `/`. +- In `ec/mod.rs`, `finalize_writes_the_edge_cookie_under_the_removed_prefix` reads the context from a request with a removed prefix through `EcContext::read_from_request`, creates an identifier, runs `ec_finalize_response` and checks that the Edge Cookie is under the prefix. `a_withdrawal_under_a_removed_prefix_also_expires_the_root_cookies` runs a withdrawal the same way and checks that the Edge Cookie and the resolved marker are expired under the prefix and under `/`, and only under `/` with no prefix. +- In `ec/resolve.rs`, `resolve_writes_under_the_removed_prefix_of_an_addressed_request` reads the context from the resolve request itself, as an adapter does, runs `handle_ec_resolve` and checks that the Edge Cookie and its marker are under the prefix. In `crates/trusted-server-adapter-fastly`, `app.rs` holds `dispatch_set_tester_writes_under_the_removed_prefix`, which sends `/_ts/set-tester` through the router with a removed prefix and checks the cookie's path. The clear route has no addressed test of its own. -Two pieces of production code build the prefix the writers use, being `for_request` in `read_from_request_with_geo_status` (`ec/mod.rs`), which all three `read_from_request*` functions of `EcContext` reach, and the use of the context's prefix in `handle_ec_resolve` (`ec/resolve.rs`). No test checks a cookie written under a removed prefix through either, so no listed test fails if either is removed. Two tests do pass a removed prefix through a context reader. In `publisher.rs`, `a_source_answering_per_request_sends_each_request_to_its_own_origin` sends `/a.example` and `/b.example` through `EcContext::read_from_request`, by way of `run_publisher_proxy_with`, and asserts only the origin each request goes to. The Fastly test above reaches `read_from_request_resolving_geo`, because the adapter reads the Edge Cookie context before it runs a tester route, and checks only the tester cookie, whose prefix `run_named_route` builds apart from the context. No test sends a request with a removed prefix through `handle_ec_resolve`, and none covers an empty removed prefix reading the jar. +Two pieces of production code build the prefix the writers use, being `for_request` in `read_from_request_with_geo_status` (`ec/mod.rs`), which all three `read_from_request*` functions of `EcContext` reach, and the use of the context's prefix in `handle_ec_resolve` (`ec/resolve.rs`). Each has a test that fails without it. With the context built from `services.public_prefix().clone()`, `finalize_writes_the_edge_cookie_under_the_removed_prefix` fails with the Edge Cookie at `Path=/`, and with `handle_ec_resolve` writing under `services.public_prefix()`, `resolve_writes_under_the_removed_prefix_of_an_addressed_request` fails with both cookies at `Path=/`. Two other tests pass a removed prefix through a context reader and check no cookie the context writes. In `publisher.rs`, `a_source_answering_per_request_sends_each_request_to_its_own_origin` sends `/a.example` and `/b.example` through `EcContext::read_from_request`, by way of `run_publisher_proxy_with`, and asserts only the origin each request goes to. The Fastly test above reaches `read_from_request_resolving_geo`, because the adapter reads the Edge Cookie context before it runs a tester route, and checks only the tester cookie, whose prefix `run_named_route` builds apart from the context. -**Open questions.** Each of these waits for a decision and for a code change, and this revision changes no code. +**Open questions.** This one waits for a decision. -1. Whether `RequestAddress::new` keeps refusing a removed prefix that is not a cookie path, or leaves the check to `under_path`, which alone stops a cookie attribute being injected. The refusal is what changes the answer for `/a;b/news`. If `new` keeps the refusal, `AddressingError::InvalidPath` and its message have to name the removed prefix as well as the continuing path. -2. Whether `handle_request_cookies` treats an empty or `/` removed prefix as no prefix, so that a request whose cookies are written under `/` also reads the last pair of a name, as a request with no prefix does. A test would check it. -3. Whether to add a test that sends a request with a removed prefix through `EcContext::read_from_request` and `ec_finalize_response` and checks the path of the cookies written, and one that does the same through `handle_ec_resolve`, so that removing either piece of code that builds the writers' prefix fails a test. -4. Whether a withdrawal also expires a cookie of the same name under `/`, beside the expiry under the publisher's path, so that the withdrawal reaches an Edge Cookie written before this change. A cookie under `/` is one cookie for every publisher that uses its name, so expiring it affects all of them. -5. Whether to correct, with the code, the comments and pages this change makes untrue. - - The module comment of `addressing.rs`, the request addressing spec (held with every other design document of the stack in IABTechLab/trusted-server#1342) and the section on addressing in `docs/guide/configuration.md` say that core never writes the removed prefix into a response and that publishers on one host share cookies. +1. Whether to correct, with the code, the pages this change makes untrue. The code comments it made untrue are corrected. + - The request addressing spec (held with every other design document of the stack in IABTechLab/trusted-server#1342) and the section on addressing in `docs/guide/configuration.md` say that core never writes the removed prefix into a response and that publishers on one host share cookies. - The request addressing spec also says that this design "keeps those cookies with the publisher", after naming the consent cookies, `sharedId` and the `Cookie` header forwarded to the origin. This design does not isolate the consent cookies or `sharedId`, and the forwarded header still carries every cookie under `/`. - The description of the Edge Cookie in `docs/guide/edge-cookies.md` says `Path=/`. - - The rustdoc of `PublicPrefix::for_request` says that publishers served from one host by path "never receive each other's cookies", which holds only for cookies written under a publisher's path, such as the ones core writes after this change. - - The doc of `parse_cookies_to_jar_keeping_first` in `cookies.rs` states as a fact that a browser lists the cookie with the longer `Path` first, where RFC 6265 section 5.4 step 2 makes it a SHOULD. - - The doc of `is_cookie_path` has absorbed the doc of `is_continuing_path`, and the docs of `PublicPrefix` and `EcContext::public_prefix` describe names and not the path. ## 3. What is not in this change @@ -116,12 +111,12 @@ Two pieces of production code build the prefix the writers use, being `for_reque - **Cookies a module or an integration writes.** A module's own `Set-Cookie` headers pass through core unchanged unless they name a cookie under the public prefix (`reserved_response_effect` in `ec/module.rs`), so their `Path` is the module's choice. The GPT diagnostics cookie `__Host-ts-console`, which `crates/ad-tag/google/src/diagnostics.rs` writes, has to stay `Path=/`, because browsers accept a cookie with the `__Host-` prefix only with `Path=/` (`draft-ietf-httpbis-rfc6265bis`, section 4.1.3.2). - **Nested prefixes.** If one publisher's prefix lies inside another's, such as `/news` and `/news/sport`, the cookies under the outer prefix are also sent on requests under the inner one. - **The client script's paths and cookies.** The script calls paths at the root of the page's host, which carry no publisher part. Its core calls `/auction` (`crates/trusted-server-js/lib/src/core/request.ts`). Of its fourteen integrations, `ec_client_fixed` posts to `/_ts/api/v1/ec/resolve`, `gpt` fetches `/_ts/page-bids` and rewrites its SDK to `/integrations/gpt`, `prebid` defaults its auction endpoint to `/auction`, `creative` calls `/first-party/sign` and `/first-party/proxy-rebuild`, and `aps`, `datadome`, `didomi`, `google_tag_manager`, `lockr`, `permutive` and `sourcepoint` aim their SDK, renderer or API paths at `/integrations//` on the page's origin. Three integrations also write cookies with `Path=/` from the page, being the eids cookie in `prebid` and the consent cookies in `osano` and `sourcepoint`. On a host addressed by path these requests are addressed by host, so the browser no longer sends them the cookies core wrote under the publisher's path, and the cookies core writes in answer to them, such as the resolve endpoint's, take `Path=/`. The cookies the page writes stay shared. The fix is for the server to tell the script the removed prefix in the page, as the server tells the script the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each path and use it as each cookie's path. That change touches the script's core and twelve of its fourteen integrations, being `aps`, `creative`, `datadome`, `didomi`, `ec_client_fixed`, `google_tag_manager`, `gpt`, `lockr`, `osano`, `permutive`, `prebid` and `sourcepoint`, and is left for a change of its own. -- **Expiring root cookies left from before.** Core expires a cookie under the path of the request it answers, so a root Edge Cookie or resolved marker written before this change stays until its `Max-Age` ends, and a withdrawal does not remove it. +- **Moving root cookies left from before.** Core does not write a root Edge Cookie or resolved marker left from before this change again under the publisher's path, so either stays under `/` until its `Max-Age` ends or a withdrawal expires it. A withdrawal on an addressed request does not expire a pull-sync marker under `/`, which lasts at most an hour. - **Partitioning other first-party storage.** Local storage and other storage the page script keeps belong to the host, not the path. ## 4. Acceptance -The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester_writes_under_the_removed_prefix` in `crates/trusted-server-adapter-fastly/src/app.rs`, which `cargo test-fastly` runs. `cargo test -p trusted-server-core`, `cargo test-fastly`, `cargo clippy -p trusted-server-core --all-targets -- -D warnings`, `cargo clippy-fastly` and `cargo fmt --all -- --check` pass. With no `RemovedPathPrefix` on a request, every `Set-Cookie` core writes is as before. The existing tests check the unchanged output byte for byte for the Edge Cookie and its expiry, the pull-sync marker and its expiry, and the tester cookie and its clearing, and check the resolved marker and its expiry for their `Path` only. The tests that Open questions names join this acceptance when they are written. +The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester_writes_under_the_removed_prefix` in `crates/trusted-server-adapter-fastly/src/app.rs`, which `cargo test-fastly` runs. `finalize_writes_the_edge_cookie_under_the_removed_prefix` and `resolve_writes_under_the_removed_prefix_of_an_addressed_request` each fail when the production code they cover is reverted, as section 2 says. `cargo test -p trusted-server-core`, `cargo test-fastly`, `cargo clippy -p trusted-server-core --all-targets -- -D warnings`, `cargo clippy-fastly` and `cargo fmt --all -- --check` pass. With no `RemovedPathPrefix` on a request, or one that is empty or `/`, every `Set-Cookie` core writes is as before and the jar keeps the last pair of a name. The existing tests check the unchanged output byte for byte for the Edge Cookie and its expiry, the pull-sync marker and its expiry, and the tester cookie and its clearing, and check the resolved marker and its expiry for their `Path` only. On an addressed request a withdrawal expires the Edge Cookie and the resolved marker under the removed prefix and under `/`, checked byte for byte. ## Review log @@ -130,8 +125,9 @@ The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester ## Revision record -| Date | Change | -| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-10-15 | First draft. | -| 2026-10-15 | Fixes from review round 1. What a refused removed prefix becomes, the rollout for returning visitors, the jar rule for an empty prefix, the adapter table, tests and acceptance for the Fastly tester change, the code no test reaches, the consent tools that write `Path=/`, signed origins, the cookies left out, the open questions and the review log. | -| 2026-10-15 | Corrections from review round 2. The tests that pass a removed prefix through the `EcContext` readers, the twelve integrations of the client script that use root-relative paths or write cookies, three more untrue statements under Open questions and the review log. | +| Date | Change | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 2026-10-15 | First draft. | +| 2026-10-15 | Fixes from review round 1. What a refused removed prefix becomes, the rollout for returning visitors, the jar rule for an empty prefix, the adapter table, tests and acceptance for the Fastly tester change, the code no test reaches, the consent tools that write `Path=/`, signed origins, the cookies left out, the open questions and the review log. | +| 2026-10-15 | Corrections from review round 2. The tests that pass a removed prefix through the `EcContext` readers, the twelve integrations of the client script that use root-relative paths or write cookies, three more untrue statements under Open questions and the review log. | +| 2026-10-15 | Code fixes for open questions 1 to 4. `RequestAddress::new` accepts any removed prefix again and `under_path` holds the one check, a removed prefix that is empty, `/` or not a cookie path reads the jar as no prefix does, tests reach the production wiring through the context and the resolve endpoint, and a withdrawal also expires the Edge Cookie and the resolved marker under `/`. The code comments open question 5 listed are corrected, and the remaining open question keeps the pages. | From 47911a0844c6a8bc0f2a183dae4473e0649454d2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:55:05 +0100 Subject: [PATCH 15/21] The changelog records cookies written under the removed prefix 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. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 966b4c9221..a52591bda9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. - One Fastly service can serve several publishers. A `__KEY` selector that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. A selector without the placeholder reads one blob as before. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - A deployment may address a request by its path before its configuration is chosen, so one host serves several publishers, each under a path of its own and from its own app-config blob. It supplies a `RequestAddressing` from `trusted_server_core::addressing`, through `trusted_server_adapter_fastly::run_with_addressing` or `TrustedServerApp::routes_with_addressing` on the other adapters. The hook answers either that a request is addressed by its host, as before, or the selector values that take the place of `{host}`, the path the request continues with and the prefix it removed. The first value whose blob exists is read and a miss on every value is answered `421`, never from a key the hook did not name. The request is routed at the continuing path with its query kept, and carries the prefix as a `RemovedPathPrefix` extension, which a deployment's own middleware puts back into its pages. Adapters that read one blob ignore the values. With no hook every request is served as before. +- On a request addressed by path, core writes the cookies it owns, being the Edge Cookie, its resolved marker, the pull-sync marker and the tester cookie, with the removed prefix as their `Path`, so the browser sends them only on requests under that publisher's path. A request with no removed prefix, an empty one, `/` or one that cannot be a cookie's `Path` writes `Path=/` as before. Where core writes under the prefix, the Edge Cookie and the pull-sync marker are read from the first cookie of each name, which is the one under the publisher's path in a browser that lists longer paths first, as RFC 6265 recommends, and elsewhere from the last, as before. A withdrawal on an addressed request expires the Edge Cookie and its resolved marker under the prefix and under `/`, and a cookie under `/` is shared by every publisher on the host that uses its name. Cookies an origin, a module, a consent tool or the page writes under `/` are still sent on every publisher's requests and forwarded to every origin that receives the `Cookie` header. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. Core lists those middleware with `supplied_middleware(builders)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. - A builder registers its module from the compiled auction plan with `with_plan_registration` and checks its settings against the plan with `with_plan_validator`, so a module whose page support follows what the plan selects is not named in `trusted-server-core`. Prebid and APS register this way. A renderer descriptor carries its builder's statement of which payload key holds the identifier the renderer picks its bid by, made with `BidRenderer::picking_bid_by`, and the page's `hb_adid` is read through that statement. From 21abbdf080d24fa8e09321158e1d7c03e591062b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 13:12:52 +0100 Subject: [PATCH 16/21] Tests withdraw a returning visitor's root Edge Cookie through the context 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. --- crates/trusted-server-core/src/ec/mod.rs | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 2e5441e69b..369dcbd896 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -2614,6 +2614,46 @@ pub(crate) mod tests { } } + /// A returning visitor whose one Edge Cookie is under `/`, where every + /// Edge Cookie was before core wrote under the removed prefix, withdraws + /// on a request addressed by path. The cookie and its resolved marker are + /// expired under the prefix and under `/`, and the row is tombstoned. + #[tokio::test] + async fn a_withdrawal_reaches_a_returning_visitors_root_edge_cookie() { + let settings = create_test_settings(); + let ec_id = valid_ec_id("c", "RtCk01"); + let req = addressed_test_request(&[("cookie", &format!("ts-ec={ec_id}"))]); + let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context") + .with_storage_withdrawn_for_test(true); + assert!(ec.ec_was_present(), "the visitor should be a returning one"); + assert!(!ec.ec_allowed(), "the gate should be closed"); + let kv_key = ec + .kv_key_for(&ec_id) + .expect("the module should own the identifier"); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + graph + .create(&kv_key, &KvEntry::minimal("ssp.example.com", "uid", 1)) + .expect("should seed the visitor's row"); + + let cookies = finalized_set_cookies(&settings, &mut ec, &graph).await; + + assert_eq!( + names_and_paths(&cookies), + [ + ("ts-ec", "/publisher-a.example"), + ("ts-ecr", "/publisher-a.example"), + ("ts-ec", "/"), + ("ts-ecr", "/"), + ] + ); + let (entry, _) = graph + .get(&kv_key) + .expect("should read the row") + .expect("should keep the row"); + assert!(!entry.consent.ok, "the withdrawal should tombstone the row"); + } + /// A cookie a module may set for itself, used to check which module /// responses keep their headers. const MODULE_EVIDENCE_COOKIE: &str = "acme-evidence=abc123; Path=/; Secure"; From ade749f9bc2a67e781d9b01a8a5b5e205c520099 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 13:13:23 +0100 Subject: [PATCH 17/21] A withdrawal under a removed prefix also expires the pull-sync marker 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. --- crates/trusted-server-core/src/ec/finalize.rs | 6 +++++ crates/trusted-server-core/src/ec/mod.rs | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 3c2034194e..88d3a7148c 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -81,6 +81,12 @@ pub async fn ec_finalize_response( if storage_withdrawn && ec_context.pull_sync_marker().was_present() { let public = ec_context.public_prefix().clone(); expire_marker(&public, ec_context.pull_sync_marker_mut(), response); + // A marker under `/` is sent on a request under a removed prefix + // as well, so it is expired there too, as the Edge Cookie is. + if public.cookie_path() != "/" { + let root = public.under_path(None); + expire_marker(&root, ec_context.pull_sync_marker_mut(), response); + } } finalize_unusable_consent( diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 369dcbd896..301d9569fd 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -2654,6 +2654,32 @@ pub(crate) mod tests { assert!(!entry.consent.ok, "the withdrawal should tombstone the row"); } + /// A pull-sync marker under `/` is sent on a request addressed by path, + /// and a withdrawal there expires it under the removed prefix and under + /// `/`, as it expires the Edge Cookie. + #[tokio::test] + async fn a_withdrawal_under_a_removed_prefix_also_expires_a_root_pull_sync_marker() { + let settings = create_test_settings(); + let cookie = format!( + "ts-ec={}; ts-ec-pull-complete=v1.1.fingerprint.tag", + valid_ec_id("c", "RtCk02") + ); + let req = addressed_test_request(&[("cookie", &cookie)]); + let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context") + .with_storage_withdrawn_for_test(true); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + + let cookies = finalized_set_cookies(&settings, &mut ec, &graph).await; + + let marker_paths: Vec<&str> = names_and_paths(&cookies) + .into_iter() + .filter(|(name, _)| *name == "ts-ec-pull-complete") + .map(|(_, path)| path) + .collect(); + assert_eq!(marker_paths, ["/publisher-a.example", "/"]); + } + /// A cookie a module may set for itself, used to check which module /// responses keep their headers. const MODULE_EVIDENCE_COOKIE: &str = "acme-evidence=abc123; Path=/; Secure"; From 0490f31a64b0b168310925b886da1df36de0926c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 13:14:02 +0100 Subject: [PATCH 18/21] A withdrawal under a removed prefix tombstones the row of each Edge Cookie 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. --- crates/trusted-server-core/src/cookies.rs | 30 +++++++ crates/trusted-server-core/src/ec/finalize.rs | 19 +++-- crates/trusted-server-core/src/ec/mod.rs | 85 ++++++++++++++++++- 3 files changed, 123 insertions(+), 11 deletions(-) diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index 67ee0cc94e..25f9702ffe 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -87,6 +87,29 @@ pub fn handle_request_cookies( } } +/// Every value of the cookie `name` in the request's `Cookie` header, in the +/// order the browser sent them, parsed as [`handle_request_cookies`] parses +/// them. +/// +/// A browser sends one pair for each cookie of a name it holds, such as one +/// under `/` and one under a publisher's path, where a [`CookieJar`] keeps +/// one. A missing or non-UTF-8 header gives none. +#[must_use] +pub(crate) fn cookie_values(req: &Request, name: &str) -> Vec { + let Some(header_value) = req + .headers() + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + else { + return Vec::new(); + }; + Cookie::split_parse(header_value.trim()) + .filter_map(Result::ok) + .filter(|cookie| cookie.name() == name) + .map(|cookie| cookie.value().to_owned()) + .collect() +} + /// Returns the named value from the request's selected `Cookie` header. /// /// Values are trimmed and may contain additional `=` characters. A missing or @@ -209,6 +232,13 @@ mod tests { } } + #[test] + fn cookie_values_gives_every_value_of_a_name_in_the_order_sent() { + let req = build_request(Some("ts-ec=publisher; other=1; ts-ec=root")); + assert_eq!(cookie_values(&req, "ts-ec"), ["publisher", "root"]); + assert!(cookie_values(&build_request(None), "ts-ec").is_empty()); + } + #[test] fn test_parse_cookies_to_jar_empty() { let cookie_str = ""; diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 88d3a7148c..59959c4034 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -585,15 +585,16 @@ fn finalize_unusable_consent( /// The identity-graph keys a withdrawal must tombstone. /// -/// Both the `ts-ec` cookie the request carried and the active identifier are -/// turned into keys by the module that owns them, so the tombstone lands on -/// the row the live identifier is stored under rather than on the raw cookie -/// value. An identifier no module this deployment reads owns produces no key -/// and is dropped, and with no module selected the built-in HMAC grammar -/// decides, as +/// The `ts-ec` cookie the request carried, any other of that name a request +/// under a removed prefix carried, such as one under `/`, and the active +/// identifier are each turned into keys by the module that owns them, so the +/// tombstone lands on the row the live identifier is stored under rather than +/// on the raw cookie value. An identifier no module this deployment reads owns +/// produces no key and is dropped, and with no module selected the built-in +/// HMAC grammar decides, as /// [`AcceptedModules::canonical_kv_key`](super::module::AcceptedModules::canonical_kv_key) -/// does. The two collapse to one key when they are the same identity written -/// two ways. +/// does. Two of them collapse to one key when they are the same identity +/// written two ways. fn withdrawal_kv_keys(ec_context: &EcContext) -> HashSet { let mut keys = HashSet::new(); @@ -601,6 +602,8 @@ fn withdrawal_kv_keys(ec_context: &EcContext) -> HashSet { keys.insert(cookie_kv_key); } + keys.extend(ec_context.other_cookie_ec_kv_keys()); + if let Some(active_kv_key) = ec_context.ec_kv_key() { keys.insert(active_kv_key); } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 301d9569fd..6c05ffe957 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -195,12 +195,19 @@ pub use generation::{ struct RequestEc { /// EC ID from the Edge Cookie, `-ec`, if present. cookie_ec: Option, + /// The values of the other Edge Cookies, see [`parse_ec_from_request`]. + other_cookie_ecs: Vec, /// Pull-sync completeness marker, if present. pull_sync_marker: Option, } /// Parses EC identity from request cookies in a single pass. /// +/// Where `public` writes under a removed prefix, the browser sends the Edge +/// Cookie under the publisher's path and any of the same name under `/`, and +/// the jar keeps one. The values of the others are kept apart, so a +/// withdrawal reaches them too. +/// /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails @@ -209,11 +216,22 @@ fn parse_ec_from_request( public: &PublicPrefix, ) -> Result> { let jar = handle_request_cookies(req)?; + let name = public.cookie_name(COOKIE_EC); let cookie_ec = jar .as_ref() - .and_then(|j| j.get(&public.cookie_name(COOKIE_EC))) + .and_then(|j| j.get(&name)) .map(cookie::Cookie::value) .and_then(|value| request_ec_id_if_allowed(value, "Edge Cookie")); + let other_cookie_ecs = if public.cookie_path() == "/" { + Vec::new() + } else { + crate::cookies::cookie_values(req, &name) + .into_iter() + .filter(|value| { + Some(value) != cookie_ec.as_ref() && ec_id_has_only_allowed_chars(value) + }) + .collect() + }; let pull_sync_marker = jar .as_ref() .and_then(|j| j.get(&public.cookie_name(COOKIE_EC_PULL_COMPLETE))) @@ -222,6 +240,7 @@ fn parse_ec_from_request( Ok(RequestEc { cookie_ec, + other_cookie_ecs, pull_sync_marker, }) } @@ -262,6 +281,10 @@ pub struct EcContext { /// request. Stored separately from `ec_value` because the header may /// take precedence, but revocation still needs the cookie value. cookie_ec_value: Option, + /// The values of the other Edge Cookies a request under a removed prefix + /// carried, such as one under `/`, which the jar did not keep. A + /// withdrawal tombstones their rows too. + other_cookie_ec_values: Vec, /// Whether an EC ID was found on the incoming request (header or cookie). ec_was_present: bool, /// Whether a new EC ID was generated during this request. @@ -433,7 +456,8 @@ impl EcContext { geo_status: consent::GeoStatus<'_>, ) -> Result> { let geo_info = geo_status.info(); - let parsed = parse_ec_from_request(req, services.public_prefix())?; + let public_prefix = services.public_prefix().for_request(req); + let parsed = parse_ec_from_request(req, &public_prefix)?; // Take the selected module once. It is used here to decide whether // the incoming cookie value is a usable identifier, to read the @@ -560,6 +584,7 @@ impl EcContext { Ok(Self { ec_value, cookie_ec_value: parsed.cookie_ec, + other_cookie_ec_values: parsed.other_cookie_ecs, ec_was_present, ec_generated: false, consent, @@ -574,7 +599,7 @@ impl EcContext { response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, - public_prefix: services.public_prefix().for_request(req), + public_prefix, client_eids: None, module_eids, pull_sync_marker: PullSyncMarkerState::from_cookie(parsed.pull_sync_marker), @@ -925,6 +950,15 @@ impl EcContext { .and_then(|value| self.kv_key_for(value)) } + /// The identity-graph keys for the other Edge Cookies a request under a + /// removed prefix carried, such as one under `/`, which a withdrawal + /// tombstones with [`cookie_ec_kv_key`](Self::cookie_ec_kv_key). + pub(crate) fn other_cookie_ec_kv_keys(&self) -> impl Iterator + '_ { + self.other_cookie_ec_values + .iter() + .filter_map(|value| self.kv_key_for(value)) + } + /// Returns whether the Edge Cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -1229,6 +1263,7 @@ impl EcContext { Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), + other_cookie_ec_values: Vec::new(), ec_value, ec_generated: false, consent, @@ -1278,6 +1313,7 @@ impl EcContext { Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), + other_cookie_ec_values: Vec::new(), ec_value, ec_generated: false, consent, @@ -1316,6 +1352,7 @@ impl EcContext { Self { ec_value, cookie_ec_value, + other_cookie_ec_values: Vec::new(), ec_was_present, ec_generated, consent, @@ -2680,6 +2717,48 @@ pub(crate) mod tests { assert_eq!(marker_paths, ["/publisher-a.example", "/"]); } + /// A browser can hold an Edge Cookie under the publisher's path and one + /// of another value under `/`, such as one a request addressed by host + /// created, and sends both on a request under the path. The jar keeps + /// one, and a withdrawal there tombstones the row of each. + #[tokio::test] + async fn a_withdrawal_under_a_removed_prefix_tombstones_each_edge_cookies_row() { + let settings = create_test_settings(); + let under_path = valid_ec_id("d", "PathCk"); + let under_root = valid_ec_id("e", "RootCk"); + let cookie = format!("ts-ec={under_path}; ts-ec={under_root}"); + let req = addressed_test_request(&[("cookie", &cookie)]); + let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context") + .with_storage_withdrawn_for_test(true); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let kv_keys: Vec = [&under_path, &under_root] + .into_iter() + .map(|ec_id| { + ec.kv_key_for(ec_id) + .expect("the module should own the identifier") + }) + .collect(); + for kv_key in &kv_keys { + graph + .create(kv_key, &KvEntry::minimal("ssp.example.com", "uid", 1)) + .expect("should seed a live row"); + } + + finalized_set_cookies(&settings, &mut ec, &graph).await; + + for kv_key in &kv_keys { + let (entry, _) = graph + .get(kv_key) + .expect("should read the row") + .expect("should keep the row"); + assert!( + !entry.consent.ok, + "the withdrawal should tombstone `{kv_key}`" + ); + } + } + /// A cookie a module may set for itself, used to check which module /// responses keep their headers. const MODULE_EVIDENCE_COOKIE: &str = "acme-evidence=abc123; Path=/; Secure"; From 028e056b19cb217be90bf55a4b6607e594cb91e1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 13:14:37 +0100 Subject: [PATCH 19/21] The for_request rustdoc says a nested prefix still receives the outer cookies The rustdoc of PublicPrefix::for_request said a cookie under one publisher's path is not sent on another publisher's requests. That is untrue where one publisher's path lies under another's, such as /news and /news/sport, which section 3 of the cookies by path design lists. The rustdoc now names that exception. --- crates/trusted-server-core/src/prefix.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/prefix.rs b/crates/trusted-server-core/src/prefix.rs index fb6dbf937e..d07fbcb7e5 100644 --- a/crates/trusted-server-core/src/prefix.rs +++ b/crates/trusted-server-core/src/prefix.rs @@ -142,7 +142,8 @@ impl PublicPrefix { /// /// A browser sends a cookie only on requests under its `Path`, so a /// cookie written under one publisher's path is not sent on another - /// publisher's requests. A cookie written under `/` is sent on them all. + /// publisher's requests, unless that publisher's path lies under it. A + /// cookie written under `/` is sent on them all. #[must_use] pub fn for_request(&self, request: &http::Request) -> Self { self.clone().under_path( From fc804e599834174b518fa534fdcc2e983fedbda4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 13:15:07 +0100 Subject: [PATCH 20/21] The cookies by path design and changelog take the withdrawal fixes and two open questions The design and the changelog entry say that a withdrawal under a removed prefix also expires the pull-sync marker under / and tombstones the row of each Edge Cookie the request carried. The design lists the new tests, and says that a withdrawal on a request addressed by host reaches only the cookies under /. The changelog adds that an Edge Cookie a visitor already holds under / stays there. Open question 2 records why core does not move a root Edge Cookie that a returning visitor holds under the publisher's path. A Cookie header does not say which path a cookie was written under (RFC 6265 section 4.2.2). A move on every returning visitor's response would make each of those responses private through enforce_set_cookie_cache_privacy. A move made once needs new state to record it. And while the client script calls /auction at the root, a moved cookie would no longer reach that auction. Open question 3 records that the Osano and Sourcepoint mirrors write the consent cookies with Path=/, and that they are left for the client script's own change. Open question 1 names the two code comments it says are corrected. --- CHANGELOG.md | 2 +- .../2026-10-15-cookies-by-path-design.md | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a82aa8205..9f3f630a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,7 +143,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. - One Fastly service can serve several publishers. A `__KEY` selector that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. A selector without the placeholder reads one blob as before. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - A deployment may address a request by its path before its configuration is chosen, so one host serves several publishers, each under a path of its own and from its own app-config blob. It supplies a `RequestAddressing` from `trusted_server_core::addressing`, through `trusted_server_adapter_fastly::run_with_addressing` or `TrustedServerApp::routes_with_addressing` on the other adapters. The hook answers either that a request is addressed by its host, as before, or the selector values that take the place of `{host}`, the path the request continues with and the prefix it removed. The first value whose blob exists is read and a miss on every value is answered `421`, never from a key the hook did not name. The request is routed at the continuing path with its query kept, and carries the prefix as a `RemovedPathPrefix` extension, which a deployment's own middleware puts back into its pages. Adapters that read one blob ignore the values. With no hook every request is served as before. -- On a request addressed by path, core writes the cookies it owns, being the Edge Cookie, its resolved marker, the pull-sync marker and the tester cookie, with the removed prefix as their `Path`, so the browser sends them only on requests under that publisher's path. A request with no removed prefix, an empty one, `/` or one that cannot be a cookie's `Path` writes `Path=/` as before. Where core writes under the prefix, the Edge Cookie and the pull-sync marker are read from the first cookie of each name, which is the one under the publisher's path in a browser that lists longer paths first, as RFC 6265 recommends, and elsewhere from the last, as before. A withdrawal on an addressed request expires the Edge Cookie and its resolved marker under the prefix and under `/`, and a cookie under `/` is shared by every publisher on the host that uses its name. Cookies an origin, a module, a consent tool or the page writes under `/` are still sent on every publisher's requests and forwarded to every origin that receives the `Cookie` header. +- On a request addressed by path, core writes the cookies it owns, being the Edge Cookie, its resolved marker, the pull-sync marker and the tester cookie, with the removed prefix as their `Path`, so the browser sends them only on requests under that publisher's path. A request with no removed prefix, an empty one, `/` or one that cannot be a cookie's `Path` writes `Path=/` as before. Where core writes under the prefix, the Edge Cookie and the pull-sync marker are read from the first cookie of each name, which is the one under the publisher's path in a browser that lists longer paths first, as RFC 6265 recommends, and elsewhere from the last, as before. A withdrawal on an addressed request expires the Edge Cookie, its resolved marker and the pull-sync marker under the prefix and under `/`, and tombstones the row of each Edge Cookie the request carried. A cookie under `/` is shared by every publisher on the host that uses its name, and an Edge Cookie a visitor already holds under `/` stays under `/` until it expires or is withdrawn. Cookies an origin, a module, a consent tool or the page writes under `/` are still sent on every publisher's requests and forwarded to every origin that receives the `Cookie` header. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. Core lists those middleware with `supplied_middleware(builders)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. - A builder registers its module from the compiled auction plan with `with_plan_registration` and checks its settings against the plan with `with_plan_validator`, so a module whose page support follows what the plan selects is not named in `trusted-server-core`. Prebid and APS register this way. A renderer descriptor carries its builder's statement of which payload key holds the identifier the renderer picks its bid by, made with `BidRenderer::picking_bid_by`, and the page's `hb_adid` is read through that statement. diff --git a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md index db4f7942d7..5a5e9dba55 100644 --- a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -4,7 +4,7 @@ **Author:** 51Degrees (contributed), for Tech Lab review **Related specs:** `2026-10-13-request-addressing-design.md`, `2026-10-12-prefix-module-design.md` **Issues:** IABTechLab/trusted-server#1328 -**Last updated:** 2026-10-15 +**Last updated:** 2026-10-16 > **Why this spec exists.** On a host that serves several publishers by path, every cookie core writes has `Path=/`, so the browser sends each publisher's cookies to every other publisher's pages and origin. This change writes the cookies core owns under each publisher's path. Cookies written under `/` by an origin, a module or the page script stay shared, and section 3 lists them. @@ -77,46 +77,49 @@ Consent cookies are written by a consent tool in the browser, not by core, and e **Configuration.** None. The path comes from the request, and a deployment that addresses no request by path writes `Path=/` as today. No setting is added or changed. -**Consent and privacy.** Writing core's own cookies under a publisher's path stops the browser sending a publisher's Edge Cookie, resolved marker, pull-sync marker and tester cookie on requests under another publisher's path, so another publisher's origin does not receive the ones written after this change. The change does not isolate consent, because core writes no consent cookie and each consent cookie keeps the `Path` its tool gave it, as above. `Path` is also no security boundary, because every publisher on the host is one origin to the browser. RFC 6265 section 8.5 says a resource from one path might be able to access cookies stored for another, and section 8.6 says the `Path` attribute gives no integrity protection, because a response can set a cookie with any `Path`. A withdrawal expires the Edge Cookie, the resolved marker and the pull-sync marker under the request's removed prefix, the path they were written under, so the expiry reaches the cookies the browser holds for that publisher. `expire_ec_cookie`, which core calls only on a withdrawal, also expires the Edge Cookie and the resolved marker under `/` when the prefix carries a path, so a root Edge Cookie written before this change does not survive the withdrawal. A cookie under `/` is one cookie for every publisher on the host that uses its name, so that expiry reaches each of them, and publishers that keep core's own public prefix, `ts`, all use the name `ts-ec`. The pull-sync marker is expired under the removed prefix only, and a marker under `/` lasts at most an hour (`MARKER_MAX_AGE_SECS` in `ec/pull_sync_marker.rs`). +**Consent and privacy.** Writing core's own cookies under a publisher's path stops the browser sending a publisher's Edge Cookie, resolved marker, pull-sync marker and tester cookie on requests under another publisher's path, so another publisher's origin does not receive the ones written after this change. The change does not isolate consent, because core writes no consent cookie and each consent cookie keeps the `Path` its tool gave it, as above. `Path` is also no security boundary, because every publisher on the host is one origin to the browser. RFC 6265 section 8.5 says a resource from one path might be able to access cookies stored for another, and section 8.6 says the `Path` attribute gives no integrity protection, because a response can set a cookie with any `Path`. A withdrawal expires the Edge Cookie, the resolved marker and the pull-sync marker under the request's removed prefix, the path they were written under, so the expiry reaches the cookies the browser holds for that publisher. When the prefix carries a path, the withdrawal also expires each of the three under `/`, the Edge Cookie and the resolved marker in `expire_ec_cookie`, which core calls only on a withdrawal, and the pull-sync marker in `ec_finalize_response`, so a root cookie written before this change, or by a request addressed by host, does not survive the withdrawal. A cookie under `/` is one cookie for every publisher on the host that uses its name, so that expiry reaches each of them, and publishers that keep core's own public prefix, `ts`, all use the name `ts-ec`. A browser that holds an Edge Cookie under the publisher's path and one of another value under `/` sends both, and the jar keeps one, so on a request under a removed prefix the withdrawal tombstones the identity-graph row of each Edge Cookie the request carried, as it does the row of the one the jar kept (`withdrawal_kv_keys` in `ec/finalize.rs`). A withdrawal on a request addressed by host, such as a call the client script makes at the root of the host, expires only the cookies under `/`, because the browser does not send that request the cookies under a publisher's path and core does not know the path. The cookies under the publisher's path are expired on the next request under that path that carries the withdrawal. **Failure handling.** A removed prefix that is not a cookie path is kept off a cookie by `under_path`, as above, and the request is addressed as its hook answered. A `Set-Cookie` value that fails to render is skipped with a warning, as today, except on the tester routes, which return `TrustedServerError::InvalidHeaderValue`. -**Rollout.** A request with no prefix writes `Path=/` and reads its jar as today, so a deployment that supplies no addressing hook, or one whose hook answers "by host", is unchanged. On a deployment already addressing by path, core writes a cookie under the publisher's path only in a response where it writes that cookie at all. For the Edge Cookie that is when core creates an identifier for a visitor who has none, or replaces an orphaned one, which is an identifier whose row the identity graph has lost. A returning visitor's response writes no Edge Cookie. The root Edge Cookie and resolved marker written before this change therefore stay in that visitor's browser for up to a year (`COOKIE_MAX_AGE` in `ec/cookies.rs`), are still sent to every publisher's pages and origin on the host, and are still read, so the identity continues. Nothing moves that cookie under the publisher's path, and before its `Max-Age` ends only a withdrawal, which expires it under `/` as above, removes it. A browser can hold a root cookie and a cookie under the publisher's path of one name, for example after core writes a new Edge Cookie for a visitor who still has the root one. The jar's first-pair rule then picks the cookie under the publisher's path, provided the browser lists the longer path first. +**Rollout.** A request with no prefix writes `Path=/` and reads its jar as today, so a deployment that supplies no addressing hook, or one whose hook answers "by host", is unchanged. On a deployment already addressing by path, core writes a cookie under the publisher's path only in a response where it writes that cookie at all. For the Edge Cookie that is when core creates an identifier for a visitor who has none, or replaces an orphaned one, which is an identifier whose row the identity graph has lost. A returning visitor's response writes no Edge Cookie. The root Edge Cookie and resolved marker written before this change therefore stay in that visitor's browser for up to a year (`COOKIE_MAX_AGE` in `ec/cookies.rs`), are still sent to every publisher's pages and origin on the host, and are still read, so the identity continues. Nothing moves that cookie under the publisher's path, because a request does not say which path a cookie it carries was written under, and open question 2 sets out what a move would cost. Before its `Max-Age` ends only a withdrawal, which expires it under `/` as above, removes it. A browser can hold a root cookie and a cookie under the publisher's path of one name, for example after core writes a new Edge Cookie for a visitor who still has the root one. The jar's first-pair rule then picks the cookie under the publisher's path, provided the browser lists the longer path first. **Tests.** In `crates/trusted-server-core`: - In `prefix.rs`, a prefix with no path, an empty one or `/` gives `/` and equals the prefix with no path, `for_request` takes the removed prefix, and a prefix holding `;`, a space or non-ASCII, or not starting with `/`, keeps `/`. - In `ec/cookies.rs`, the Edge Cookie, its expiry and the resolved marker carry `Path=/publisher-a.example` under that prefix, and `Path=/` without it. `an_expiry_under_a_removed_prefix_also_expires_the_root_cookies` checks byte for byte that an expiry under that prefix also expires the Edge Cookie and the resolved marker under `/`. - In `ec/pull_sync_marker.rs` and `tester_cookie.rs`, the marker and the tester cookie carry the prefix path. -- In `cookies.rs`, on an addressed request the first of two pairs of one name is read, and with no prefix the last is read, as today, and so is the last with a removed prefix that is empty, `/` or `/a;b`. +- In `cookies.rs`, on an addressed request the first of two pairs of one name is read, and with no prefix the last is read, as today, and so is the last with a removed prefix that is empty, `/` or `/a;b`. `cookie_values_gives_every_value_of_a_name_in_the_order_sent` checks that every pair of one name is returned. - In `evidence.rs`, `RequestInfo::cookie` returns the first of two consent cookies of one name. - In `addressing.rs`, `a_prefix_that_is_not_a_cookie_path_still_addresses_the_request` checks that the hook in the module's rustdoc addresses `/a;b/news` with the selector value `a;b`, the continuing path `/news` and the removed prefix `/a;b`, as before this change, and that core writes that request's cookies under `/`. -- In `ec/mod.rs`, `finalize_writes_the_edge_cookie_under_the_removed_prefix` reads the context from a request with a removed prefix through `EcContext::read_from_request`, creates an identifier, runs `ec_finalize_response` and checks that the Edge Cookie is under the prefix. `a_withdrawal_under_a_removed_prefix_also_expires_the_root_cookies` runs a withdrawal the same way and checks that the Edge Cookie and the resolved marker are expired under the prefix and under `/`, and only under `/` with no prefix. +- In `ec/mod.rs`, `finalize_writes_the_edge_cookie_under_the_removed_prefix` reads the context from a request with a removed prefix through `EcContext::read_from_request`, creates an identifier, runs `ec_finalize_response` and checks that the Edge Cookie is under the prefix. `a_withdrawal_under_a_removed_prefix_also_expires_the_root_cookies` runs a withdrawal the same way and checks that the Edge Cookie and the resolved marker are expired under the prefix and under `/`, and only under `/` with no prefix. `a_withdrawal_reaches_a_returning_visitors_root_edge_cookie` runs a withdrawal for a returning visitor whose only Edge Cookie is under `/`, and checks those four expiries and that the visitor's row is tombstoned. `a_withdrawal_under_a_removed_prefix_also_expires_a_root_pull_sync_marker` checks that a pull-sync marker the request carried is expired under the prefix and under `/`. `a_withdrawal_under_a_removed_prefix_tombstones_each_edge_cookies_row` sends an Edge Cookie under the path and one of another value under `/`, and checks that the rows of both are tombstoned. - In `ec/resolve.rs`, `resolve_writes_under_the_removed_prefix_of_an_addressed_request` reads the context from the resolve request itself, as an adapter does, runs `handle_ec_resolve` and checks that the Edge Cookie and its marker are under the prefix. In `crates/trusted-server-adapter-fastly`, `app.rs` holds `dispatch_set_tester_writes_under_the_removed_prefix`, which sends `/_ts/set-tester` through the router with a removed prefix and checks the cookie's path. The clear route has no addressed test of its own. Two pieces of production code build the prefix the writers use, being `for_request` in `read_from_request_with_geo_status` (`ec/mod.rs`), which all three `read_from_request*` functions of `EcContext` reach, and the use of the context's prefix in `handle_ec_resolve` (`ec/resolve.rs`). Each has a test that fails without it. With the context built from `services.public_prefix().clone()`, `finalize_writes_the_edge_cookie_under_the_removed_prefix` fails with the Edge Cookie at `Path=/`, and with `handle_ec_resolve` writing under `services.public_prefix()`, `resolve_writes_under_the_removed_prefix_of_an_addressed_request` fails with both cookies at `Path=/`. Two other tests pass a removed prefix through a context reader and check no cookie the context writes. In `publisher.rs`, `a_source_answering_per_request_sends_each_request_to_its_own_origin` sends `/a.example` and `/b.example` through `EcContext::read_from_request`, by way of `run_publisher_proxy_with`, and asserts only the origin each request goes to. The Fastly test above reaches `read_from_request_resolving_geo`, because the adapter reads the Edge Cookie context before it runs a tester route, and checks only the tester cookie, whose prefix `run_named_route` builds apart from the context. -**Open questions.** This one waits for a decision. +**Open questions.** These wait for a decision. -1. Whether to correct, with the code, the pages this change makes untrue. The code comments it made untrue are corrected. +1. Whether to correct, with the code, the pages this change makes untrue. The code comments it made untrue, in the rustdoc of `PublicPrefix::for_request` and of `parse_cookies_to_jar_keeping_first` in `cookies.rs`, are corrected. - The request addressing spec (held with every other design document of the stack in IABTechLab/trusted-server#1342) and the section on addressing in `docs/guide/configuration.md` say that core never writes the removed prefix into a response and that publishers on one host share cookies. - The request addressing spec also says that this design "keeps those cookies with the publisher", after naming the consent cookies, `sharedId` and the `Cookie` header forwarded to the origin. This design does not isolate the consent cookies or `sharedId`, and the forwarded header still carries every cookie under `/`. - The description of the Edge Cookie in `docs/guide/edge-cookies.md` says `Path=/`. +2. Whether core should move a root Edge Cookie a returning visitor holds under the publisher's path. A `Cookie` header carries only names and values, and RFC 6265 section 4.2.2 says a server cannot tell from it for which paths a cookie is valid, so core cannot tell a lone root cookie from one under the publisher's path. A move on every returning visitor's response would put a `Set-Cookie` on each of them, and `enforce_set_cookie_cache_privacy` in `response_privacy.rs` makes every response that carries one `private, max-age=0` and removes its edge cache headers, where today a returning visitor's response carries no Edge Cookie. A move made once needs something new to record that it happened, such as a cookie written only under the publisher's path. While the client script calls `/auction` at the root of the host (section 3), a cookie moved under the publisher's path is also no longer sent on that call, so that call's auction would run without the identifier it receives today. Until this is decided a root Edge Cookie stays under `/` until its `Max-Age` ends or a withdrawal expires it, as the rollout above says. +3. Whether the client script's Osano and Sourcepoint mirrors should write the consent cookies under the publisher's path. The Osano mirror writes `us_privacy`, `__gpp`, `__gpp_sid` and `euconsent-v2`, and the Sourcepoint mirror writes `__gpp` and `__gpp_sid`, each with `Path=/` fixed in `integrations/osano/index.ts` and `integrations/sourcepoint/index.ts`, so consent given on one publisher's pages is read on another's. That is a change to the client script, left with its paths for a change of its own (section 3), so this change makes no code change for those cookies. + ## 3. What is not in this change - **Cookies under `/`.** A cookie written under `/`, by an origin, by a module, by a consent tool not set to the publisher's path, or by core before this change, is sent under every publisher's path and forwarded to every origin on the host that is not signed. Core cannot tell which publisher wrote it. A deployment that needs an origin's cookies kept apart rewrites their `Path` with a middleware of its own, as it puts the prefix into links. - **Cookies a module or an integration writes.** A module's own `Set-Cookie` headers pass through core unchanged unless they name a cookie under the public prefix (`reserved_response_effect` in `ec/module.rs`), so their `Path` is the module's choice. The GPT diagnostics cookie `__Host-ts-console`, which `crates/ad-tag/google/src/diagnostics.rs` writes, has to stay `Path=/`, because browsers accept a cookie with the `__Host-` prefix only with `Path=/` (`draft-ietf-httpbis-rfc6265bis`, section 4.1.3.2). - **Nested prefixes.** If one publisher's prefix lies inside another's, such as `/news` and `/news/sport`, the cookies under the outer prefix are also sent on requests under the inner one. - **The client script's paths and cookies.** The script calls paths at the root of the page's host, which carry no publisher part. Its core calls `/auction` (`crates/trusted-server-js/lib/src/core/request.ts`). Of its fourteen integrations, `ec_client_fixed` posts to `/_ts/api/v1/ec/resolve`, `gpt` fetches `/_ts/page-bids` and rewrites its SDK to `/integrations/gpt`, `prebid` defaults its auction endpoint to `/auction`, `creative` calls `/first-party/sign` and `/first-party/proxy-rebuild`, and `aps`, `datadome`, `didomi`, `google_tag_manager`, `lockr`, `permutive` and `sourcepoint` aim their SDK, renderer or API paths at `/integrations//` on the page's origin. Three integrations also write cookies with `Path=/` from the page, being the eids cookie in `prebid` and the consent cookies in `osano` and `sourcepoint`. On a host addressed by path these requests are addressed by host, so the browser no longer sends them the cookies core wrote under the publisher's path, and the cookies core writes in answer to them, such as the resolve endpoint's, take `Path=/`. The cookies the page writes stay shared. The fix is for the server to tell the script the removed prefix in the page, as the server tells the script the public prefix with `data-ts-public-prefix`, and for the script to put the prefix before each path and use it as each cookie's path. That change touches the script's core and twelve of its fourteen integrations, being `aps`, `creative`, `datadome`, `didomi`, `ec_client_fixed`, `google_tag_manager`, `gpt`, `lockr`, `osano`, `permutive`, `prebid` and `sourcepoint`, and is left for a change of its own. -- **Moving root cookies left from before.** Core does not write a root Edge Cookie or resolved marker left from before this change again under the publisher's path, so either stays under `/` until its `Max-Age` ends or a withdrawal expires it. A withdrawal on an addressed request does not expire a pull-sync marker under `/`, which lasts at most an hour. +- **Moving root cookies left from before.** Core does not write a root Edge Cookie or resolved marker left from before this change again under the publisher's path, so either stays under `/` until its `Max-Age` ends or a withdrawal expires it. Open question 2 sets out why and what a move would cost. - **Partitioning other first-party storage.** Local storage and other storage the page script keeps belong to the host, not the path. ## 4. Acceptance -The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester_writes_under_the_removed_prefix` in `crates/trusted-server-adapter-fastly/src/app.rs`, which `cargo test-fastly` runs. `finalize_writes_the_edge_cookie_under_the_removed_prefix` and `resolve_writes_under_the_removed_prefix_of_an_addressed_request` each fail when the production code they cover is reverted, as section 2 says. `cargo test -p trusted-server-core`, `cargo test-fastly`, `cargo clippy -p trusted-server-core --all-targets -- -D warnings`, `cargo clippy-fastly` and `cargo fmt --all -- --check` pass. With no `RemovedPathPrefix` on a request, or one that is empty or `/`, every `Set-Cookie` core writes is as before and the jar keeps the last pair of a name. The existing tests check the unchanged output byte for byte for the Edge Cookie and its expiry, the pull-sync marker and its expiry, and the tester cookie and its clearing, and check the resolved marker and its expiry for their `Path` only. On an addressed request a withdrawal expires the Edge Cookie and the resolved marker under the removed prefix and under `/`, checked byte for byte. +The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester_writes_under_the_removed_prefix` in `crates/trusted-server-adapter-fastly/src/app.rs`, which `cargo test-fastly` runs. `finalize_writes_the_edge_cookie_under_the_removed_prefix` and `resolve_writes_under_the_removed_prefix_of_an_addressed_request` each fail when the production code they cover is reverted, as section 2 says. `cargo test -p trusted-server-core`, `cargo test-fastly`, `cargo clippy -p trusted-server-core --all-targets -- -D warnings`, `cargo clippy-fastly` and `cargo fmt --all -- --check` pass. With no `RemovedPathPrefix` on a request, or one that is empty or `/`, every `Set-Cookie` core writes is as before and the jar keeps the last pair of a name. The existing tests check the unchanged output byte for byte for the Edge Cookie and its expiry, the pull-sync marker and its expiry, and the tester cookie and its clearing, and check the resolved marker and its expiry for their `Path` only. On an addressed request a withdrawal expires the Edge Cookie and the resolved marker under the removed prefix and under `/`, checked byte for byte, expires a pull-sync marker the request carried under both, and tombstones the row of each Edge Cookie the request carried. `a_withdrawal_under_a_removed_prefix_also_expires_a_root_pull_sync_marker` and `a_withdrawal_under_a_removed_prefix_tombstones_each_edge_cookies_row` each fail when the production code they cover is reverted. ## Review log @@ -131,3 +134,4 @@ The tests listed under "Tests" in section 2 pass, including `dispatch_set_tester | 2026-10-15 | Fixes from review round 1. What a refused removed prefix becomes, the rollout for returning visitors, the jar rule for an empty prefix, the adapter table, tests and acceptance for the Fastly tester change, the code no test reaches, the consent tools that write `Path=/`, signed origins, the cookies left out, the open questions and the review log. | | 2026-10-15 | Corrections from review round 2. The tests that pass a removed prefix through the `EcContext` readers, the twelve integrations of the client script that use root-relative paths or write cookies, three more untrue statements under Open questions and the review log. | | 2026-10-15 | Code fixes for open questions 1 to 4. `RequestAddress::new` accepts any removed prefix again and `under_path` holds the one check, a removed prefix that is empty, `/` or not a cookie path reads the jar as no prefix does, tests reach the production wiring through the context and the resolve endpoint, and a withdrawal also expires the Edge Cookie and the resolved marker under `/`. The code comments open question 5 listed are corrected, and the remaining open question keeps the pages. | +| 2026-10-16 | Code fixes for the rollout and privacy finding of review round 1. On an addressed request a withdrawal also expires a pull-sync marker under `/` and tombstones the row of each Edge Cookie the request carried, and the rustdoc of `for_request` names nested prefixes. Open question 2 records why core does not move a root Edge Cookie, and open question 3 records that the Osano and Sourcepoint mirrors are left with the client script. | From 97dd5457ed6eab3922d1ede10f5e6bf1e8b95329 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 21:20:09 +0100 Subject: [PATCH 21/21] The design header names the pull request that carries it --- docs/superpowers/specs/2026-10-15-cookies-by-path-design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md index 5a5e9dba55..3206d71b6b 100644 --- a/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -1,8 +1,9 @@ # Design Spec: Core keeps a publisher's cookies under that publisher's path when one host serves several -**Status:** Proposed, 2026-10-15 +**Status:** Proposed, 2026-10-15. Raised as draft pull request IABTechLab/trusted-server#1400 (from jwrosewell/trusted-server#45), for Tech Lab review. **Author:** 51Degrees (contributed), for Tech Lab review **Related specs:** `2026-10-13-request-addressing-design.md`, `2026-10-12-prefix-module-design.md` +**Related PRs:** IABTechLab/trusted-server#1400, jwrosewell/trusted-server#45 **Issues:** IABTechLab/trusted-server#1328 **Last updated:** 2026-10-16