diff --git a/CHANGELOG.md b/CHANGELOG.md index f31b37193..c008a174a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module is asked for the host the request was addressed to, never one a forwarded header names, and is given nothing of the crawler's connection. Its answer is held for that host by the loading cache until its refresh is due. A module that cannot answer has its last held answer served, and with none held, or with no rule in the whole file, the answer is `503` and never an empty file. On Fastly the file is answered before the Edge Cookie steps and the request filters. Without the section `/robots.txt` reaches the publisher's origin as before. - `ts keys rotate` and `ts keys deactivate` rotate and retire the request signing keys from an operator's machine. They write the two stores the service links as `jwks_store` and `signing_keys` through the `fastly` CLI, with the token from `FASTLY_API_TOKEN`. A private key reaches `fastly` on its standard input and never in its arguments, and a key id no platform can store is refused before anything is written. A command that cannot read the config store writes nothing, and a delete that failed part way can be run again. - 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, 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. - One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store 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. Each host's blob names its permission policy file and pins its SHA-256, and the push keeps each file `--permissions` supplies once, in a shared entry under `permissions_`, written before the blob, which a live push writes a minute after it writes an entry. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `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. Where text a middleware writes into a script would end the script, or begin ` { - 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") @@ -2606,6 +2606,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/crates/trusted-server-core/src/addressing.rs b/crates/trusted-server-core/src/addressing.rs index 3b69686a5..5972c3e7a 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,7 +95,9 @@ 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 /// @@ -172,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); @@ -276,6 +281,30 @@ fn addressed_handler( } } +/// 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';') +} + +/// 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. @@ -414,6 +443,50 @@ mod tests { } } + /// 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()) diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index e778defeb..25f9702ff 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -29,10 +29,33 @@ 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. +/// +/// 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) { + 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. 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 /// @@ -47,7 +70,16 @@ 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 under_removed_prefix = req + .extensions() + .get::() + .and_then(|prefix| crate::addressing::cookie_path_for(prefix.as_str())) + .is_some(); + let jar = if under_removed_prefix { + 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"); @@ -55,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 @@ -128,6 +183,62 @@ mod tests { assert_eq!(jar.get("c1").expect("should have cookie c1").value(), "v2"); } + /// 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")); + 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")); + } + + /// 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 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/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac67fbbe0..bdc31be02 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -5,7 +5,10 @@ //! 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 +//! 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) @@ -53,8 +56,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(), ) } @@ -115,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, @@ -161,9 +181,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 +211,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 +300,86 @@ 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, + /// 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", + "/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] + 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/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 3c2034194..59959c403 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( @@ -579,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(); @@ -595,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 64e9ebcbe..0fa1c61cd 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 @@ -568,6 +592,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, @@ -582,7 +607,7 @@ impl EcContext { response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, - public_prefix: services.public_prefix().clone(), + public_prefix, client_eids: None, module_eids, pull_sync_marker: PullSyncMarkerState::from_cookie(parsed.pull_sync_marker), @@ -933,6 +958,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 { @@ -959,7 +993,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 @@ -1242,6 +1278,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, @@ -1290,6 +1327,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, @@ -1327,6 +1365,7 @@ impl EcContext { Self { ec_value, cookie_ec_value, + other_cookie_ec_values: Vec::new(), ec_was_present, ec_generated, consent, @@ -2524,6 +2563,211 @@ 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 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 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 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 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"; 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 836b40cb0..2063762d1 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 9934982b1..b54fcf496 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) } @@ -1042,6 +1042,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. @@ -1055,16 +1070,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!( @@ -1116,6 +1122,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() { diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 6db5cced1..b679d1fb6 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"); diff --git a/crates/trusted-server-core/src/prefix.rs b/crates/trusted-server-core/src/prefix.rs index 9fd511295..b6432a5b8 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,9 +63,13 @@ 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, + cookie_path: Option, } impl PublicPrefix { @@ -96,6 +101,7 @@ impl PublicPrefix { } Some(Self { dotted: parts.join("."), + cookie_path: None, }) } @@ -113,6 +119,47 @@ impl PublicPrefix { format!("{}-{name}", self.dotted) } + /// The same prefix, its cookies written under `path`. + /// + /// `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 = 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.map(str::to_owned), + ..self + } + } + + /// The prefix for `request`, its cookies written under the prefix + /// request addressing removed, or under `/` when none was removed or + /// [`Self::under_path`] keeps `/` for it. + /// + /// 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, 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( + 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 +227,7 @@ impl TrustedServerPrefix { pub fn public() -> PublicPrefix { PublicPrefix { dotted: TRUSTED_SERVER_HEAD.to_owned(), + cookie_path: None, } } @@ -354,6 +402,49 @@ 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(), "/"); + for path in ["", "/"] { + assert_eq!( + PublicPrefix::default().under_path(Some(path)), + PublicPrefix::default(), + "`{path}` should count as no removed prefix" + ); + } + } + + #[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 8dc59647a..39fd67ecf 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(); 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 000000000..3206d71b6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-cookies-by-path-design.md @@ -0,0 +1,138 @@ +# Design Spec: Core keeps a publisher's cookies under that publisher's path when one host serves several + +**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 + +> **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` 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 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, 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. + +```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` | + +`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. + +**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 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`). 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. + +**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. + +**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. + +**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. + +| 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 | + +**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. 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, 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`. `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. `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.** These wait for a decision. + +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. 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, 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 + +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 + +| 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. | +| 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. |