From ef80b76450f3f8acf21e8e17019d45d28cce7fb1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 5 Oct 2026 21:18:46 +0100 Subject: [PATCH 01/15] A module names the browser's cookies and the shared store keys Core gains a prefix module, `PrefixModule`, which gives two things for the publisher a request resolved to: a public prefix that starts every name the browser holds, being cookies and first-party storage keys, and a server key prefix that starts every key in a store publishers share. Core's own module gives `ts` and `ts.` and runs when `[prefix] module` selects nothing, so a stock build's names are what they were. The registry resolves the selection as it does geo and device, refuses a name nothing this deployment runs supplies, and asks the module once when the registry is built, so a module naming a value its call never carries is refused at startup. Every adapter puts the resolved prefixes on the request's services, and the Fastly entry point keeps what its request resolved to for the finishing steps that run after the request has been handed to the router. The robots.txt holding and the loading cache take the prefix from the request's services rather than from the settings. --- crates/loading-cache/51degrees/src/tests.rs | 8 +- crates/trusted-server-adapter-axum/src/app.rs | 46 +- .../src/app.rs | 46 +- .../trusted-server-adapter-fastly/src/app.rs | 50 +- .../trusted-server-adapter-fastly/src/lib.rs | 68 ++- .../src/middleware.rs | 30 +- crates/trusted-server-adapter-spin/src/app.rs | 46 +- .../src/integrations/registry.rs | 286 +++++++++++- crates/trusted-server-core/src/lib.rs | 1 + .../src/platform/loading_cache.rs | 14 +- .../src/platform/server_key.rs | 67 +-- .../trusted-server-core/src/platform/types.rs | 49 ++ crates/trusted-server-core/src/prefix.rs | 442 ++++++++++++++++++ .../src/robots_txt/holding.rs | 60 ++- crates/trusted-server-core/src/settings.rs | 26 ++ 15 files changed, 1158 insertions(+), 81 deletions(-) create mode 100644 crates/trusted-server-core/src/prefix.rs diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index 5296b8b68a..898dbc524d 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -197,11 +197,7 @@ fn spec(detachable: bool) -> CacheSpec { } fn key(name: &str) -> StoreKey { - StoreKey::new( - &ServerKeyPrefix::for_domain("www.example.com"), - "test", - name, - ) + StoreKey::new(&ServerKeyPrefix::new("ts", "www.example.com"), "test", name) } /// A loader that counts its runs, waits long enough for every other caller to @@ -462,7 +458,7 @@ async fn two_publishers_never_share_an_entry() { let services = services(Arc::clone(&kv)); let caches = LoadingCaches::in_caller(); let runs = Arc::new(AtomicUsize::new(0)); - let of = |domain: &str| StoreKey::new(&ServerKeyPrefix::for_domain(domain), "test", "k"); + let of = |domain: &str| StoreKey::new(&ServerKeyPrefix::new("ts", domain), "test", "k"); let one = caches .get_or_load( diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 99b8570e1a..390f54a8a1 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -250,7 +250,16 @@ impl AppState { if let Some(module) = self.registry.device_module() { services = services.with_device_module(module); } - services + // The publisher this request resolved to names every key in a shared + // store and every name the browser holds. + let prefixes = self.registry.prefixes( + &self.settings, + trusted_server_core::module_context::ModuleRequest::of( + ctx.request(), + services.client_info(), + ), + ); + services.with_prefixes(prefixes) } } @@ -872,6 +881,41 @@ mod tests { assume_single_jurisdiction = true "#; + /// Every request's services carry the prefixes of the publisher its + /// settings describe, which start every shared store key and every name + /// the browser holds. + #[test] + fn a_requests_services_carry_the_publishers_prefixes() { + let settings = Settings::from_toml( + r#" + [publisher] + domain = "www.publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret-32-bytes-ok" + + [geo] + assume_single_jurisdiction = true + "#, + ) + .expect("should parse the settings"); + let state = build_state_with_settings(settings).expect("should build the state"); + let request = request_builder() + .method("GET") + .uri("https://www.publisher.example/") + .body(edgezero_core::body::Body::empty()) + .expect("should build the request"); + let ctx = RequestContext::new(request, PathParams::default()); + + let services = state.services_for_request(&ctx); + + assert_eq!( + services.server_key_prefix().as_str(), + "ts.publisher.example" + ); + assert_eq!(services.public_prefix().as_str(), "ts"); + } + /// Builds application state directly, bypassing the composition root's /// startup check, so the per-request behavior can be exercised with a /// selection the adapter cannot supply. diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 0d56bb2eee..59dda3db98 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -377,7 +377,16 @@ impl AppState { if let Some(module) = self.registry.device_module() { services = services.with_device_module(module); } - services + // The publisher this request resolved to names every key in a shared + // store and every name the browser holds. + let prefixes = self.registry.prefixes( + &self.settings, + trusted_server_core::module_context::ModuleRequest::of( + ctx.request(), + services.client_info(), + ), + ); + services.with_prefixes(prefixes) } } @@ -1053,6 +1062,41 @@ mod tests { ); } + /// Every request's services carry the prefixes of the publisher its + /// settings describe, which start every shared store key and every name + /// the browser holds. + #[test] + fn a_requests_services_carry_the_publishers_prefixes() { + let settings = Settings::from_toml( + r#" + [publisher] + domain = "www.publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret-32-bytes-ok" + + [geo] + assume_single_jurisdiction = true + "#, + ) + .expect("should parse the settings"); + let state = build_state_with_settings(settings).expect("should build the state"); + let request = request_builder() + .method("GET") + .uri("https://www.publisher.example/") + .body(edgezero_core::body::Body::empty()) + .expect("should build the request"); + let ctx = RequestContext::new(request, PathParams::default()); + + let services = state.services_for_request(&ctx); + + assert_eq!( + services.server_key_prefix().as_str(), + "ts.publisher.example" + ); + assert_eq!(services.public_prefix().as_str(), "ts"); + } + #[test] fn cloudflare_config_prefers_manifest_default_key() { let value = serde_json::json!({ diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 7aeb36035f..bc4dc52451 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -125,6 +125,7 @@ use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; +use trusted_server_core::module_context::ModuleRequest; use trusted_server_core::permissions::PermissionState; use trusted_server_core::platform::{ ClientInfo, GeoInfo, LoadingCaches, PlatformKvStore, RuntimeServices, StoreName, @@ -482,7 +483,13 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime if let Some(module) = state.registry.device_module() { services = services.with_device_module(module); } - services + // The publisher this request resolved to, its tenant included, names + // every key in a shared store and every name the browser holds. + let prefixes = state.registry.prefixes( + &state.settings, + ModuleRequest::of(ctx.request(), services.client_info()), + ); + services.with_prefixes(prefixes) } /// Builds the services graph handed to a module on a response-side finalize @@ -1519,15 +1526,18 @@ impl TrustedServerApp { } fn routes_for_state(state: &Arc) -> RouterService { - let mut router = RouterService::builder().middleware(FinalizeResponseMiddleware::new( - Arc::clone(&state.settings), - build_geo_module(&state.settings, Arc::new(FastlyPlatformGeo)), - build_finalize_services( - &state.settings, - Arc::clone(&state.default_kv_store), - Arc::clone(&state.loading_caches), - ), - )); + let mut router = RouterService::builder().middleware( + FinalizeResponseMiddleware::new( + Arc::clone(&state.settings), + build_geo_module(&state.settings, Arc::new(FastlyPlatformGeo)), + build_finalize_services( + &state.settings, + Arc::clone(&state.default_kv_store), + Arc::clone(&state.loading_caches), + ), + ) + .with_registry(Arc::clone(&state.registry)), + ); let fallback_handler = fallback_route_handler(Arc::clone(state)); @@ -1794,6 +1804,26 @@ mod tests { TrustedServerApp::routes_for_state(&state) } + /// Every request's services carry the prefixes of the publisher it + /// resolved to, which start every shared store key and every name the + /// browser holds. + #[test] + fn per_request_services_carry_the_publishers_prefixes() { + let state = build_state_from_settings(test_settings()).expect("should build test state"); + let context = RequestContext::new( + empty_request(Method::GET, "/article"), + PathParams::default(), + ); + + let services = build_per_request_services(&state, &context); + + assert_eq!( + services.server_key_prefix().as_str(), + "ts.test-publisher.com" + ); + assert_eq!(services.public_prefix().as_str(), "ts"); + } + #[test] fn per_request_services_register_the_fastly_template_assembler() { let state = build_state_from_settings(test_settings()).expect("should build test state"); diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index fbc76e494c..2a9a1f0ee2 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -36,6 +36,7 @@ use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::build_geo_module; use trusted_server_core::platform::{ClientInfo, RuntimeServices, UnavailableKvStore}; +use trusted_server_core::prefix::Prefixes; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; @@ -588,7 +589,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re Some(settings) => { // The entry point is synchronous host code, so it drives the async // module seam at the same boundary it already drives the router. - let services = entry_point_finalize_services(settings, &app_state); + let services = entry_point_finalize_services(settings, &app_state, entry_request(&req)); let registered = app_state .as_deref() .and_then(|state| state.registry.device_module()); @@ -607,6 +608,9 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such // as multiple Set-Cookie headers. + // Kept for the finishing steps' module calls, which run after the + // request has been handed to the router. + let kept = EntryRequest::of(&req); let mut response = match into_core_request(req) { Ok(mut core_req) => { core_req.extensions_mut().insert(config_store); @@ -637,7 +641,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re settings, &mut response, client_ip, - entry_point_finalize_services(settings, &app_state), + entry_point_finalize_services(settings, &app_state, kept.module_request()), )); } else { match load_settings_from_config_store(&runtime_stores) { @@ -646,7 +650,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re &settings, &mut response, client_ip, - entry_point_finalize_services(&settings, &app_state), + entry_point_finalize_services(&settings, &app_state, kept.module_request()), )); } Err(e) => { @@ -749,17 +753,24 @@ fn take_finalize_sentinel(response: &mut HttpResponse) -> bool { /// cache that holds nothing rather than no services at all. A module that /// needs the store then fails its own call instead of the entry point silently /// skipping the module. +/// +/// The prefixes come from the selected prefix module where the state was +/// built. A deployment whose state failed to build has no registry to name +/// one, so core's own stand. fn entry_point_finalize_services( settings: &Settings, app_state: &Option>, + request: ModuleRequest<'_>, ) -> RuntimeServices { match app_state { Some(state) => build_finalize_services( settings, Arc::clone(&state.default_kv_store), Arc::clone(&state.loading_caches), - ), - None => build_finalize_services(settings, Arc::new(UnavailableKvStore), Arc::default()), + ) + .with_prefixes(state.registry.prefixes(settings, request)), + None => build_finalize_services(settings, Arc::new(UnavailableKvStore), Arc::default()) + .with_prefixes(Prefixes::trusted_server(settings)), } } @@ -1091,21 +1102,56 @@ pub(crate) async fn derive_device_signals( /// The scheme is `https` when the entry point marked the request as arriving /// over TLS, which it does from Fastly's own TLS metadata. fn entry_request(req: &FastlyRequest) -> ModuleRequest<'_> { - let scheme = if req.get_header_str("fastly-ssl").is_some() { - "https" - } else { - "http" - }; ModuleRequest::new( req.get_method(), req.get_header_str(header::HOST.as_str()) .unwrap_or_default(), - scheme, + entry_scheme(req), req.get_path(), ) .with_query(req.get_query_str().unwrap_or_default()) } +/// `https` when the entry point marked the request as arriving over TLS, +/// which it does from Fastly's own TLS metadata. +fn entry_scheme(req: &FastlyRequest) -> &'static str { + if req.get_header_str("fastly-ssl").is_some() { + "https" + } else { + "http" + } +} + +/// What the client's request resolved to, owned, for the module calls that +/// run after the request itself has been handed to the router. +struct EntryRequest { + method: fastly::http::Method, + host: String, + scheme: &'static str, + path: String, + query: String, +} + +impl EntryRequest { + fn of(req: &FastlyRequest) -> Self { + Self { + method: req.get_method().clone(), + host: req + .get_header_str(header::HOST.as_str()) + .unwrap_or_default() + .to_owned(), + scheme: entry_scheme(req), + path: req.get_path().to_owned(), + query: req.get_query_str().unwrap_or_default().to_owned(), + } + } + + fn module_request(&self) -> ModuleRequest<'_> { + ModuleRequest::new(&self.method, &self.host, self.scheme, &self.path) + .with_query(&self.query) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index e0510f861f..6e822d27f8 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -19,6 +19,8 @@ use trusted_server_core::constants::{ }; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::{ROUTE_NAMESPACE, strip_diagnostic_headers}; +use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::module_context::ModuleRequest; use trusted_server_core::platform::{ClientInfo, PlatformGeo, RuntimeServices}; use trusted_server_core::settings::Settings; @@ -52,6 +54,9 @@ pub struct FinalizeResponseMiddleware { /// Built once with the application and cloned per request with that /// request's client metadata applied. services: RuntimeServices, + /// The registry whose selected prefix module names the publisher's + /// prefixes, or `None` where no application state was built. + registry: Option>, } impl FinalizeResponseMiddleware { @@ -66,8 +71,17 @@ impl FinalizeResponseMiddleware { settings, geo, services, + registry: None, } } + + /// The registry whose selected prefix module names each request's + /// publisher prefixes. + #[must_use] + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = Some(registry); + self + } } #[async_trait(?Send)] @@ -78,6 +92,20 @@ impl Middleware for FinalizeResponseMiddleware { |info| info.client_ip, ); let path = ctx.request().uri().path().to_owned(); + // The publisher this request resolved to names the keys the geo + // module's answers are held under, so the services carry its prefixes. + let client = ctx + .request() + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + let services = match &self.registry { + Some(registry) => self.services.clone().with_prefixes( + registry.prefixes(&self.settings, ModuleRequest::of(ctx.request(), &client)), + ), + None => self.services.clone(), + }; let mut response = match next.run(ctx).await { Ok(r) => r, @@ -88,7 +116,7 @@ impl Middleware for FinalizeResponseMiddleware { }; let geo_info = if geo_allowed_for_response(&response) { - let services = self.services.clone().with_client_info(ClientInfo { + let services = services.clone().with_client_info(ClientInfo { client_ip, ..ClientInfo::default() }); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index f45d67c6ae..9b4ae60e60 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -306,7 +306,16 @@ impl AppState { if let Some(module) = self.registry.device_module() { services = services.with_device_module(module); } - services + // The publisher this request resolved to names every key in a shared + // store and every name the browser holds. + let prefixes = self.registry.prefixes( + &self.settings, + trusted_server_core::module_context::ModuleRequest::of( + ctx.request(), + services.client_info(), + ), + ); + services.with_prefixes(prefixes) } } @@ -1209,6 +1218,41 @@ mod tests { ); } + /// Every request's services carry the prefixes of the publisher its + /// settings describe, which start every shared store key and every name + /// the browser holds. + #[test] + fn a_requests_services_carry_the_publishers_prefixes() { + let settings = Settings::from_toml( + r#" + [publisher] + domain = "www.publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret-32-bytes-ok" + + [geo] + assume_single_jurisdiction = true + "#, + ) + .expect("should parse the settings"); + let state = build_state_with_settings(settings).expect("should build the state"); + let request = request_builder() + .method("GET") + .uri("https://www.publisher.example/") + .body(edgezero_core::body::Body::empty()) + .expect("should build the request"); + let ctx = RequestContext::new(request, PathParams::default()); + + let services = state.services_for_request(&ctx); + + assert_eq!( + services.server_key_prefix().as_str(), + "ts.publisher.example" + ); + assert_eq!(services.public_prefix().as_str(), "ts"); + } + #[test] fn disabled_startup_accepts_dormant_multi_provider_auction_plan() { let mut settings = multi_provider_settings(); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 8b310e8763..77251effda 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -19,8 +19,9 @@ use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; use crate::middleware::{Middleware, MiddlewareChain, MiddlewarePhase, PhaseEntries}; -use crate::module_context::{ModuleCall, ModuleContext, ResolvedRequest}; +use crate::module_context::{ModuleCall, ModuleContext, ModuleRequest, ResolvedRequest}; use crate::platform::{DisabledGeo, PlatformGeo, RuntimeServices}; +use crate::prefix::{PrefixModule, Prefixes, SelectedPrefixModule}; use crate::robots_txt::{ALLOW_ALL, REFUSE_ALL, RobotsTxtContributor}; use crate::settings::Settings; @@ -673,6 +674,12 @@ pub struct IntegrationRegistration { /// Declaring one does not make it active, because the module is only asked /// to classify a request when `[device] module` names it. pub device_module: Option<(&'static str, Arc)>, + /// Prefix module this registration supplies, with the name + /// `[prefix] module` selects it by. + /// + /// Declaring one does not make it active, because the module only gives + /// the deployment's prefixes when `[prefix] module` names it. + pub prefix_module: Option<(&'static str, Arc)>, /// The rules this registration adds to `/robots.txt`, with the name /// `[robots-txt] modules` selects them by. /// @@ -708,6 +715,7 @@ impl IntegrationRegistrationBuilder { geo_module: None, ec_module: None, device_module: None, + prefix_module: None, robots_txt_contributor: None, }, } @@ -790,6 +798,18 @@ impl IntegrationRegistrationBuilder { self } + /// Declare the prefix module this registration supplies, with the name + /// `[prefix] module` selects it by. + /// + /// It only gives the deployment's prefixes when `[prefix] module` names + /// it, see [`crate::prefix`], and a module the selector does not choose is + /// logged as a warning when the registry is built, the same as geo. + #[must_use] + pub fn with_prefix_module(mut self, name: &'static str, module: Arc) -> Self { + self.registration.prefix_module = Some((name, module)); + self + } + /// Declare the rules this registration adds to `/robots.txt`, under their /// `name`. /// @@ -921,6 +941,12 @@ struct IntegrationRegistryInner { geo_module: Option>, ec_module: Option>, device_module: Option>, + // The prefix modules the running registrations supply, each with its + // name, in registration order. `[prefix] module` picks at most one. + prefix_modules: Vec<(&'static str, Arc)>, + // The prefix module `[prefix] module` resolved to, core's own when + // nothing is selected. + prefix_module: SelectedPrefixModule, } impl Default for IntegrationRegistryInner { @@ -955,6 +981,8 @@ impl Default for IntegrationRegistryInner { geo_module: None, ec_module: None, device_module: None, + prefix_modules: Vec::new(), + prefix_module: SelectedPrefixModule::default(), } } } @@ -981,6 +1009,7 @@ const DEVICE_MODULE_FASTLY: &str = "fastly"; const EDGECOOKIE_TYPE: &str = crate::ec::module::MODULE_TYPE; const DEVICE_TYPE: &str = "device"; const GEO_TYPE: &str = "geo"; +const PREFIX_TYPE: &str = crate::prefix::MODULE_TYPE; const ROBOTS_TXT_TYPE: &str = crate::robots_txt::MODULE_TYPE; impl IntegrationRegistryInner { @@ -1246,6 +1275,51 @@ fn resolve_device_module( Ok(resolved) } +/// Resolves `[prefix] module` against the registered prefix modules, or to +/// core's own when nothing is selected, and asks the module for the prefixes +/// of the publisher the settings describe, so a module that names something +/// its call never carries is refused here rather than on every request. +/// +/// # Errors +/// +/// A configuration error when the selector names no registered module, or +/// when the module names a value other than the request and the settings. +fn resolve_prefix_module( + settings: &Settings, + inner: &IntegrationRegistryInner, +) -> Result> { + let selected = match settings.prefix.module.as_deref() { + None => SelectedPrefixModule::trusted_server(), + Some(written) => { + let module = + named_module(PREFIX_TYPE, "prefix", written, &inner.prefix_modules, inner)?; + let name = inner + .prefix_modules + .iter() + .find(|(_, offered)| Arc::ptr_eq(offered, &module)) + .map_or(PREFIX_TYPE, |(name, _)| *name); + SelectedPrefixModule::new(name, module) + } + }; + let chosen = inner + .prefix_modules + .iter() + .find(|(name, _)| *name == selected.name()) + .map(|(_, module)| module); + warn_unselected("prefix", PREFIX_TYPE, chosen, &inner.prefix_modules); + let trial = ModuleRequest::new(&Method::GET, &settings.publisher.domain, "https", "/"); + selected.prefixes(settings, trial).map_err(|withheld| { + Report::new(TrustedServerError::Configuration { + message: format!( + "`[prefix] module` selects `{}`, which cannot give its prefixes: {withheld}. A \ + prefix module is handed the request and the settings, and nothing else", + selected.name() + ), + }) + })?; + Ok(selected) +} + /// Refuses a `[robots-txt] modules` that names something nothing this /// deployment runs supplies. /// @@ -1836,6 +1910,10 @@ impl IntegrationRegistry { claim_module_name(&mut claimed, "device", name, registration.integration_id)?; inner.device_modules.push((name, module)); } + if let Some((name, module)) = registration.prefix_module { + claim_module_name(&mut claimed, "prefix", name, registration.integration_id)?; + inner.prefix_modules.push((name, module)); + } if let Some((name, contributor)) = registration.robots_txt_contributor { claim_module_name( &mut claimed, @@ -1893,6 +1971,7 @@ impl IntegrationRegistry { let geo_module = resolve_geo_module(settings, &inner)?; inner.ec_module = resolve_ec_module(settings, &inner); inner.device_module = resolve_device_module(settings, &inner)?; + inner.prefix_module = resolve_prefix_module(settings, &inner)?; inner.geo_module = geo_module; check_robots_txt_selection(settings, &inner)?; if let Some(config) = settings.robots_txt.as_ref() { @@ -1946,6 +2025,33 @@ impl IntegrationRegistry { self.inner.device_module.clone() } + /// The prefix module `[prefix] module` selects, or core's own when nothing + /// is selected. + #[must_use] + pub fn prefix_module(&self) -> &SelectedPrefixModule { + &self.inner.prefix_module + } + + /// The prefixes of the publisher `request` was resolved to under + /// `settings`, from the selected prefix module. + /// + /// The module was asked once when the registry was built, so it names + /// nothing a request does not carry. Should it still give nothing, the + /// failure is logged and core's own prefixes stand. + #[must_use] + pub fn prefixes(&self, settings: &Settings, request: ModuleRequest<'_>) -> Prefixes { + self.inner + .prefix_module + .prefixes(settings, request) + .unwrap_or_else(|withheld| { + log::error!( + "the prefix module `{}` gave no prefixes, so core's own stand: {withheld}", + self.inner.prefix_module.name() + ); + Prefixes::trusted_server(settings) + }) + } + /// The robots.txt contributor `[robots-txt] modules` means by `written`, /// with the name it was declared under, or `None` when nothing this /// deployment runs supplies one under that name. @@ -2474,6 +2580,8 @@ impl IntegrationRegistry { geo_module: None, ec_module: None, device_module: None, + prefix_modules: Vec::new(), + prefix_module: SelectedPrefixModule::default(), }), plan: None, } @@ -2553,6 +2661,8 @@ impl IntegrationRegistry { geo_module: None, ec_module: None, device_module: None, + prefix_modules: Vec::new(), + prefix_module: SelectedPrefixModule::default(), }), plan: None, } @@ -5768,6 +5878,180 @@ mod tests { .with_module_name(GEO_PROBE_MODULE)] } + /// The names the two prefix test modules run under, as modules under + /// `crates/testing` are named. + const PREFIX_PROBE_MODULE: &str = "testing.prefix-probe"; + const PREFIX_READER_MODULE: &str = "testing.prefix-reader"; + + /// A prefix module giving `_ex` and `ex.`, so a test can tell it + /// apart from core's own. + #[derive(Debug)] + struct ExamplePrefix; + + impl ExamplePrefix { + fn server_key(&self, settings: &Settings) -> crate::platform::ServerKeyPrefix { + crate::platform::ServerKeyPrefix::new("ex", &settings.publisher.domain) + } + } + + impl PrefixModule for ExamplePrefix { + fn public_prefix( + &self, + _call: ModuleCall<'_>, + ) -> Result { + Ok(crate::prefix::PublicPrefix::new(["_ex"]).expect("should accept the prefix")) + } + + fn server_key_prefix( + &self, + call: ModuleCall<'_>, + ) -> Result { + call.inject(self, Self::server_key) + } + } + + /// A prefix module naming the reader's evidence, which its call never + /// carries. + #[derive(Debug)] + struct EvidenceReadingPrefix; + + impl EvidenceReadingPrefix { + fn public( + &self, + evidence: &dyn crate::evidence::RequestInfo, + ) -> crate::prefix::PublicPrefix { + crate::prefix::PublicPrefix::new([evidence.user_agent()]) + .expect("should accept the prefix") + } + } + + impl PrefixModule for EvidenceReadingPrefix { + fn public_prefix( + &self, + call: ModuleCall<'_>, + ) -> Result { + call.inject(self, Self::public) + } + + fn server_key_prefix( + &self, + _call: ModuleCall<'_>, + ) -> Result { + Ok(crate::platform::ServerKeyPrefix::new("ev", "example.com")) + } + } + + fn prefix_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("prefix-probe") + .with_prefix_module(PREFIX_PROBE_MODULE, Arc::new(ExamplePrefix)) + .build(), + )) + } + + fn prefix_reader_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("prefix-reader") + .with_prefix_module(PREFIX_READER_MODULE, Arc::new(EvidenceReadingPrefix)) + .build(), + )) + } + + fn prefix_probe_builders() -> [crate::integrations::IntegrationBuilder; 2] { + [ + crate::integrations::IntegrationBuilder::new( + "prefix-probe", + "seam-probe", + prefix_probe_registration, + validate_nothing, + ) + .with_module_name(PREFIX_PROBE_MODULE), + crate::integrations::IntegrationBuilder::new( + "prefix-reader", + "seam-probe", + prefix_reader_registration, + validate_nothing, + ) + .with_module_name(PREFIX_READER_MODULE), + ] + } + + /// Settings running both probe modules on `www.example.com`, with + /// `[prefix] module` naming `selected` when given. + fn settings_selecting_prefix_module(selected: Option<&str>) -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.publisher.domain = "www.example.com".to_owned(); + for id in ["prefix-probe", "prefix-reader"] { + settings + .insert_module_config("testing", &format!("testing.{id}"), &serde_json::json!({})) + .expect("should select the test module"); + } + settings.prefix.module = selected.map(str::to_owned); + settings + } + + #[test] + fn with_no_prefix_module_selected_the_registry_gives_core_ts() { + let settings = settings_selecting_prefix_module(None); + let registry = IntegrationRegistry::with_registrations(&settings, &prefix_probe_builders()) + .expect("should build the registry"); + + let prefixes = + registry.prefixes(&settings, crate::module_context::test_support::request("/")); + + assert_eq!(registry.prefix_module().name(), "ts"); + assert_eq!(prefixes.public().as_str(), "ts"); + assert_eq!(prefixes.public().cookie_name("ec"), "ts-ec"); + assert_eq!(prefixes.server_key().as_str(), "ts.example.com"); + } + + #[test] + fn the_selected_prefix_module_gives_the_prefixes() { + let settings = settings_selecting_prefix_module(Some(PREFIX_PROBE_MODULE)); + let registry = IntegrationRegistry::with_registrations(&settings, &prefix_probe_builders()) + .expect("should build the registry"); + + let prefixes = + registry.prefixes(&settings, crate::module_context::test_support::request("/")); + + assert_eq!(registry.prefix_module().name(), PREFIX_PROBE_MODULE); + assert_eq!(prefixes.public().cookie_name("ec"), "_ex-ec"); + assert_eq!(prefixes.server_key().as_str(), "ex.example.com"); + } + + #[test] + fn a_prefix_module_nothing_supplies_is_refused() { + let settings = settings_selecting_prefix_module(Some("missing")); + + let error = IntegrationRegistry::with_registrations(&settings, &prefix_probe_builders()) + .err() + .expect("a prefix module nothing supplies should be refused"); + + assert!( + error.to_string().contains("[prefix] module") && error.to_string().contains("missing"), + "{error}" + ); + } + + #[test] + fn a_prefix_module_naming_what_its_call_never_carries_is_refused() { + let settings = settings_selecting_prefix_module(Some(PREFIX_READER_MODULE)); + + let error = IntegrationRegistry::with_registrations(&settings, &prefix_probe_builders()) + .err() + .expect("a prefix module that cannot answer should be refused at startup"); + + assert!( + error.to_string().contains(PREFIX_READER_MODULE) + && error.to_string().contains("request evidence"), + "{error}" + ); + } + /// Settings whose `[geo] module` names `module`, which may be a value /// core resolves itself, such as `none` or `platform`. fn settings_with_geo_selector(module: &str) -> Settings { diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 190968ab51..25ba692242 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -62,6 +62,7 @@ pub mod openrtb; pub mod permission_signal; pub mod permissions; pub mod platform; +pub mod prefix; pub mod price_bucket; pub mod provider_table; pub mod proxy; diff --git a/crates/trusted-server-core/src/platform/loading_cache.rs b/crates/trusted-server-core/src/platform/loading_cache.rs index 372a14b2b7..f3b6cd1d55 100644 --- a/crates/trusted-server-core/src/platform/loading_cache.rs +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -298,19 +298,15 @@ mod tests { #[test] fn two_publishers_never_share_a_key() { - let key = |domain: &str| { - StoreKey::new(&ServerKeyPrefix::for_domain(domain), "page", "abc").to_string() - }; - - assert_ne!(key("example.com"), key("example.org")); - assert_ne!(key("clients.example.com"), key("public.example.com")); - assert_eq!(key("www.example.com"), key("example.com")); + crate::prefix::test_support::assert_keys_follow_the_publisher(|domain| { + StoreKey::new(&ServerKeyPrefix::new("ts", domain), "page", "abc").to_string() + }); } #[test] fn a_key_names_the_publisher_then_the_cache() { let key = StoreKey::new( - &ServerKeyPrefix::for_domain("www.example.com"), + &ServerKeyPrefix::new("ts", "www.example.com"), "robots-txt", "k1", ); @@ -333,7 +329,7 @@ mod tests { memory_entries: 1, detachable: false, }; - let key = StoreKey::new(&ServerKeyPrefix::for_domain("example.com"), "test", "k"); + let key = StoreKey::new(&ServerKeyPrefix::new("ts", "example.com"), "test", "k"); let runs = Arc::new(AtomicUsize::new(0)); for _ in 0..2 { let counted = Arc::clone(&runs); diff --git a/crates/trusted-server-core/src/platform/server_key.rs b/crates/trusted-server-core/src/platform/server_key.rs index 9058588956..435dc36dc5 100644 --- a/crates/trusted-server-core/src/platform/server_key.rs +++ b/crates/trusted-server-core/src/platform/server_key.rs @@ -2,45 +2,56 @@ use core::fmt; -use crate::settings::Settings; - /// The start of every key a publisher's entries take in a shared store, so /// two publishers sharing a store never share a key. /// -/// The publisher's domain from the loaded settings, never from the request, -/// because a `Host` header can say anything and a prefix built from one would -/// let any request start a key space of its own. A single leading `www.` is -/// folded, so `www.example.com` and `example.com` share keys, and every other -/// label is kept, so `clients.example.com` and `public.example.com` do not. +/// Written `.`, where the head names who keeps the store, such +/// as `ts`, and the domain is the publisher's. The selected prefix module +/// gives both, see [`crate::prefix`], taking the domain from the publisher the +/// request resolved to and never from the raw request, because a `Host` +/// header can say anything and a prefix built from one would let any request +/// start a key space of its own. +/// +/// A single leading `www.` is folded, so `www.example.com` and `example.com` +/// share keys, and every other label is kept, so `clients.example.com` and +/// `public.example.com` do not. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ServerKeyPrefix(String); +pub struct ServerKeyPrefix { + text: String, + domain_start: usize, +} impl ServerKeyPrefix { - /// The prefix for the publisher the settings describe. - #[must_use] - pub fn for_settings(settings: &Settings) -> Self { - Self::for_domain(&settings.publisher.domain) - } - - /// The prefix for a publisher's domain. Every shared store key's prefix is - /// made here. + /// The prefix `head` and the publisher's `domain` make. Every shared store + /// key's prefix is made here. #[must_use] - pub fn for_domain(domain: &str) -> Self { - let domain = domain.trim().to_ascii_lowercase(); + pub fn new(head: &str, domain: &str) -> Self { + let head = head.trim(); + let domain = domain.trim().trim_end_matches('.').to_ascii_lowercase(); let folded = domain.strip_prefix("www.").unwrap_or(&domain); - Self(format!("ts.{folded}")) + Self { + text: format!("{head}.{folded}"), + domain_start: head.len() + 1, + } } /// The prefix as text. #[must_use] pub fn as_str(&self) -> &str { - &self.0 + &self.text + } + + /// The publisher's domain the prefix carries, lowercased and with a + /// leading `www.` folded. + #[must_use] + pub fn domain(&self) -> &str { + &self.text[self.domain_start..] } } impl fmt::Display for ServerKeyPrefix { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) + f.write_str(&self.text) } } @@ -54,12 +65,13 @@ mod tests { ("example.com", "ts.example.com"), ("www.example.com", "ts.example.com"), ("WWW.Example.COM", "ts.example.com"), + ("example.com.", "ts.example.com"), ("clients.example.com", "ts.clients.example.com"), ("public.example.com", "ts.public.example.com"), ("www.www.example.com", "ts.www.example.com"), ] { assert_eq!( - ServerKeyPrefix::for_domain(domain).as_str(), + ServerKeyPrefix::new("ts", domain).as_str(), prefix, "{domain}" ); @@ -67,13 +79,10 @@ mod tests { } #[test] - fn the_prefix_comes_from_the_loaded_settings() { - let mut settings = Settings::default(); - settings.publisher.domain = "www.example.com".to_owned(); + fn the_domain_is_the_folded_one() { + let prefix = ServerKeyPrefix::new("ts", "www.example.com"); - assert_eq!( - ServerKeyPrefix::for_settings(&settings).as_str(), - "ts.example.com" - ); + assert_eq!(prefix.domain(), "example.com"); + assert_eq!(prefix.to_string(), "ts.example.com"); } } diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index e9a6500953..110a1f35c1 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -221,6 +221,10 @@ pub struct RuntimeServices { /// so building the services for a request bumps a reference count rather /// than copying the list, and cloning the services does the same. pub(crate) permission_signal_modules: Arc<[Arc]>, + /// The prefixes of the publisher this request was resolved to, from the + /// selected prefix module, which start every name the browser holds and + /// every key in a shared store. See [`crate::prefix`]. + pub(crate) prefixes: crate::prefix::Prefixes, } impl RuntimeServices { @@ -379,6 +383,26 @@ impl RuntimeServices { self.device_module.clone() } + /// The prefixes of the publisher this request was resolved to, being the + /// start of every name the browser holds and of every key in a shared + /// store. See [`crate::prefix`]. + #[must_use] + pub fn prefixes(&self) -> &crate::prefix::Prefixes { + &self.prefixes + } + + /// The start of every key the publisher's entries take in a shared store. + #[must_use] + pub fn server_key_prefix(&self) -> &super::ServerKeyPrefix { + self.prefixes.server_key() + } + + /// The start of every name the browser holds for the publisher. + #[must_use] + pub fn public_prefix(&self) -> &crate::prefix::PublicPrefix { + self.prefixes.public() + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -473,6 +497,17 @@ impl RuntimeServices { } } + /// Returns a clone of this instance carrying the prefixes of the publisher + /// the request was resolved to. + /// + /// Adapters apply what the selected prefix module gives, through + /// [`IntegrationRegistry::prefixes`](crate::integrations::IntegrationRegistry::prefixes), + /// alongside the other modules a request's services carry. + #[must_use] + pub fn with_prefixes(self, prefixes: crate::prefix::Prefixes) -> Self { + Self { prefixes, ..self } + } + /// Returns a clone of this instance with the loading caches replaced. #[must_use] pub fn with_loading_caches(self, caches: Arc) -> Self { @@ -540,6 +575,7 @@ pub struct RuntimeServicesBuilder { host_signals: Option>, resolved_ec_module: Option>, device_module: Option>, + prefixes: Option, permission_signal_modules: Arc<[Arc]>, } @@ -561,6 +597,7 @@ impl RuntimeServicesBuilder { host_signals: None, resolved_ec_module: None, device_module: None, + prefixes: None, permission_signal_modules: Arc::default(), } } @@ -697,6 +734,17 @@ impl RuntimeServicesBuilder { self } + /// Set the prefixes of the publisher the request was resolved to. + /// + /// Optional in the builder, because a test has no publisher to resolve, + /// and [`Prefixes::default`](crate::prefix::Prefixes::default), carrying no + /// publisher domain, stands when it is not set. Every adapter sets it. + #[must_use] + pub fn prefixes(mut self, prefixes: crate::prefix::Prefixes) -> Self { + self.prefixes = Some(prefixes); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -744,6 +792,7 @@ impl RuntimeServicesBuilder { host_signals: self.host_signals, resolved_ec_module: self.resolved_ec_module, device_module: self.device_module, + prefixes: self.prefixes.unwrap_or_default(), permission_signal_modules: self.permission_signal_modules, } } diff --git a/crates/trusted-server-core/src/prefix.rs b/crates/trusted-server-core/src/prefix.rs new file mode 100644 index 0000000000..30635d7aa5 --- /dev/null +++ b/crates/trusted-server-core/src/prefix.rs @@ -0,0 +1,442 @@ +//! The start of every name the browser holds for a publisher, and of every key +//! the publisher's entries take in a shared store. +//! +//! A deployment selects the module that gives both with `[prefix] module`. Core +//! holds the extension point, [`PrefixModule`], and its own module, +//! [`TrustedServerPrefix`], which gives `ts` and `ts.` and runs when +//! nothing is selected. +//! +//! The two prefixes answer different questions. A public prefix starts the +//! names the browser holds, being cookies, first-party storage keys and script +//! globals, so it carries no domain, because a browser already keeps those +//! apart by host. A server key prefix starts every key in a store publishers +//! share, so it carries the publisher's domain, see [`ServerKeyPrefix`]. +//! +//! A module gives the parts, and each use writes its own form from them, +//! because what is allowed differs by use: +//! +//! | Use | Form | `ts` | `_ex`, `www.example.com` | +//! | --- | --- | --- | --- | +//! | cookie | [`PublicPrefix::cookie_name`] | `ts-ec` | `_ex.www.example.com-ec` | +//! | first-party storage | [`PublicPrefix::storage_key`] | `ts-evidence` | `_ex.www.example.com-evidence` | +//! | script global | [`PublicPrefix::script_global`] | `ts` | `_ex_www_example_com` | +//! | store key | [`ServerKeyPrefix::as_str`] | `ts.example.com` | as its module gives it | + +use core::fmt; +use std::sync::Arc; + +use crate::module_context::{ModuleCall, ModuleContext, ModuleRequest, Withheld}; +use crate::permissions::PermissionSet; +use crate::platform::ServerKeyPrefix; +use crate::settings::Settings; + +/// The type folder, and the section, that selects the prefix module. +pub const MODULE_TYPE: &str = "prefix"; + +/// The name core's own module is called by when it runs. +pub const TRUSTED_SERVER_MODULE: &str = "ts"; + +/// The head of every name core's own module gives. +const TRUSTED_SERVER_HEAD: &str = "ts"; + +/// The start of every name the browser holds for a publisher, such as `_ex`. +/// +/// Made of parts, such as `_ex` and `www.example.com`, which each use joins +/// in its own form. Every part holds only ASCII letters, digits, `_`, `-` and +/// `.`, so every form is a valid cookie name, storage key and attribute value, +/// and the first part does not start with a digit, so the script global form +/// is an identifier. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicPrefix { + parts: Vec, + dotted: String, +} + +impl PublicPrefix { + /// The prefix made of `parts`, in order, or `None` when there are none, + /// when a part is empty or holds anything else, or when the first part + /// starts with a digit. + #[must_use] + pub fn new(parts: I) -> Option + where + I: IntoIterator, + S: AsRef, + { + let parts: Vec = parts + .into_iter() + .map(|part| part.as_ref().to_owned()) + .collect(); + let well_formed = parts.iter().all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + }); + let first_is_a_name = parts + .first() + .is_some_and(|first| !first.starts_with(|c: char| c.is_ascii_digit())); + if !well_formed || !first_is_a_name { + return None; + } + let dotted = parts.join("."); + Some(Self { parts, dotted }) + } + + /// The parts, in order. + #[must_use] + pub fn parts(&self) -> &[String] { + &self.parts + } + + /// The parts joined by `.`, such as `_ex.www.example.com`, which every + /// cookie name and storage key starts with. + #[must_use] + pub fn as_str(&self) -> &str { + &self.dotted + } + + /// The name of the cookie `name`, written `-`, + /// such as `ts-ec` or `_ex-ec`. + #[must_use] + pub fn cookie_name(&self, name: &str) -> String { + format!("{}-{name}", self.dotted) + } + + /// The key of the first-party storage entry `name`, written as a cookie + /// name is. + #[must_use] + pub fn storage_key(&self, name: &str) -> String { + self.cookie_name(name) + } + + /// Whether `cookie` is in the namespace this prefix starts, which is every + /// name [`Self::cookie_name`] gives, compared without regard to case. + #[must_use] + pub fn owns_cookie_name(&self, cookie: &[u8]) -> bool { + let length = self.dotted.len(); + cookie + .get(..length) + .is_some_and(|head| head.eq_ignore_ascii_case(self.dotted.as_bytes())) + && cookie.get(length) == Some(&b'-') + } + + /// The prefix as a script global, being the parts joined by `_` with every + /// character an identifier cannot hold written as `_`, such as `ts` or + /// `_ex_www_example_com`. + #[must_use] + pub fn script_global(&self) -> String { + self.parts + .iter() + .map(|part| { + part.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::() + }) + .collect::>() + .join("_") + } +} + +impl fmt::Display for PublicPrefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.dotted) + } +} + +/// Gives the start of the names the browser holds for a publisher and of the +/// keys its entries take in shared stores. +/// +/// Selected with `[prefix] module`, and core's [`TrustedServerPrefix`] runs +/// when nothing is selected. Each method is handed a [`ModuleCall`] carrying +/// the request and the deployment's settings, and +/// nothing else, and an implementation hands its own function to +/// [`ModuleCall::inject`] naming what it needs. +/// +/// The domain a server key prefix carries comes from the publisher the request +/// resolved to, being the loaded settings' `publisher.domain`, and never from +/// the raw request. +pub trait PrefixModule: Send + Sync + fmt::Debug { + /// The start of every cookie name, first-party storage key and script + /// global for the publisher this call was resolved to. + /// + /// # Errors + /// + /// [`Withheld`] when the module names a value the call does not carry. + fn public_prefix(&self, call: ModuleCall<'_>) -> Result; + + /// The start of every key the publisher's entries take in a shared store. + /// + /// # Errors + /// + /// [`Withheld`] when the module names a value the call does not carry. + fn server_key_prefix(&self, call: ModuleCall<'_>) -> Result; +} + +/// Core's own prefix module, which gives `ts`, the prefix of the cookie names +/// Trusted Server writes, and `ts.`. It runs when `[prefix]` +/// selects nothing. +#[derive(Debug, Default, Clone, Copy)] +pub struct TrustedServerPrefix; + +impl TrustedServerPrefix { + /// `ts`. + #[must_use] + pub fn public() -> PublicPrefix { + PublicPrefix { + parts: vec![TRUSTED_SERVER_HEAD.to_owned()], + dotted: TRUSTED_SERVER_HEAD.to_owned(), + } + } + + fn server_key(&self, settings: &Settings) -> ServerKeyPrefix { + ServerKeyPrefix::new(TRUSTED_SERVER_HEAD, &settings.publisher.domain) + } +} + +impl PrefixModule for TrustedServerPrefix { + fn public_prefix(&self, _call: ModuleCall<'_>) -> Result { + Ok(Self::public()) + } + + fn server_key_prefix(&self, call: ModuleCall<'_>) -> Result { + call.inject(self, Self::server_key) + } +} + +/// Both prefixes for one request, as its services carry them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Prefixes { + public: PublicPrefix, + server_key: ServerKeyPrefix, +} + +impl Prefixes { + /// The two prefixes. + #[must_use] + pub fn new(public: PublicPrefix, server_key: ServerKeyPrefix) -> Self { + Self { public, server_key } + } + + /// What `module`, called `name`, gives for the publisher `request` was + /// resolved to under `settings`. + /// + /// # Errors + /// + /// [`Withheld`] when the module names a value other than the request and + /// the settings. + pub fn resolve( + module: &dyn PrefixModule, + name: &'static str, + settings: &Settings, + request: ModuleRequest<'_>, + ) -> Result { + let context = ModuleContext::new(request).with_settings(settings); + let call = context.call(name, PermissionSet::none()); + Ok(Self { + public: module.public_prefix(call)?, + server_key: module.server_key_prefix(call)?, + }) + } + + /// What core's own module gives for the publisher `settings` describe. + #[must_use] + pub fn trusted_server(settings: &Settings) -> Self { + Self::new( + TrustedServerPrefix::public(), + TrustedServerPrefix.server_key(settings), + ) + } + + /// The start of every name the browser holds. + #[must_use] + pub fn public(&self) -> &PublicPrefix { + &self.public + } + + /// The start of every key in a shared store. + #[must_use] + pub fn server_key(&self) -> &ServerKeyPrefix { + &self.server_key + } +} + +/// The prefixes of services no adapter resolved prefixes for, which only a +/// test builds, being core's own names with no publisher domain. +impl Default for Prefixes { + fn default() -> Self { + Self::new( + TrustedServerPrefix::public(), + ServerKeyPrefix::new(TRUSTED_SERVER_HEAD, ""), + ) + } +} + +/// The selected prefix module and the name it runs under. +#[derive(Debug, Clone)] +pub struct SelectedPrefixModule { + name: &'static str, + module: Arc, +} + +impl SelectedPrefixModule { + /// `module`, which runs under `name`. + #[must_use] + pub fn new(name: &'static str, module: Arc) -> Self { + Self { name, module } + } + + /// Core's own module, which runs when nothing is selected. + #[must_use] + pub fn trusted_server() -> Self { + Self::new(TRUSTED_SERVER_MODULE, Arc::new(TrustedServerPrefix)) + } + + /// The name the module runs under. + #[must_use] + pub fn name(&self) -> &'static str { + self.name + } + + /// What the module gives for the publisher `request` was resolved to. + /// + /// # Errors + /// + /// [`Withheld`] when the module names a value other than the request and + /// the settings. + pub fn prefixes( + &self, + settings: &Settings, + request: ModuleRequest<'_>, + ) -> Result { + Prefixes::resolve(&*self.module, self.name, settings, request) + } +} + +impl Default for SelectedPrefixModule { + fn default() -> Self { + Self::trusted_server() + } +} + +/// Prefixes and checks for a module's own tests of its store keys. +#[cfg(any(test, feature = "test-utils"))] +pub mod test_support { + use super::{Prefixes, ServerKeyPrefix, TrustedServerPrefix}; + + /// The prefixes core's own module gives a publisher on `domain`. + #[must_use] + pub fn trusted_server_for(domain: &str) -> Prefixes { + Prefixes::new( + TrustedServerPrefix::public(), + ServerKeyPrefix::new(super::TRUSTED_SERVER_HEAD, domain), + ) + } + + /// Checks that the keys `key_for` makes for a publisher's domain follow + /// the publisher: two publishers never share one, `www.example.com` and + /// `example.com` do, and `clients.example.com` and `public.example.com` + /// do not. + /// + /// # Panics + /// + /// When any of the three does not hold, naming it. + pub fn assert_keys_follow_the_publisher(key_for: impl Fn(&str) -> String) { + assert_ne!( + key_for("first.example"), + key_for("second.example"), + "two publishers should never share a key" + ); + assert_eq!( + key_for("www.example.com"), + key_for("example.com"), + "a publisher's www. and bare names should share a key" + ); + assert_ne!( + key_for("clients.example.com"), + key_for("public.example.com"), + "two subdomains other than www. should not share a key" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::module_context::test_support::request; + + fn settings_for(domain: &str) -> Settings { + let mut settings = Settings::default(); + settings.publisher.domain = domain.to_owned(); + settings + } + + #[test] + fn no_module_selected_gives_exactly_ts() { + let prefixes = SelectedPrefixModule::default() + .prefixes(&settings_for("www.example.com"), request("/")) + .expect("core's module should name only what the call carries"); + + assert_eq!(prefixes.public().as_str(), "ts"); + assert_eq!(prefixes.server_key().as_str(), "ts.example.com"); + for (name, cookie) in [ + ("ec", "ts-ec"), + ("ecr", "ts-ecr"), + ("eids", "ts-eids"), + ("tester", "ts-tester"), + ] { + assert_eq!(prefixes.public().cookie_name(name), cookie); + } + assert_eq!(prefixes.public().script_global(), "ts"); + } + + #[test] + fn each_use_writes_its_own_form_from_the_parts() { + let prefix = + PublicPrefix::new(["_ex", "www.example.com"]).expect("should accept the parts"); + + assert_eq!(prefix.as_str(), "_ex.www.example.com"); + assert_eq!(prefix.cookie_name("ec"), "_ex.www.example.com-ec"); + assert_eq!( + prefix.storage_key("evidence"), + "_ex.www.example.com-evidence" + ); + assert_eq!(prefix.script_global(), "_ex_www_example_com"); + } + + #[test] + fn a_part_that_could_leave_a_name_is_refused() { + for parts in [ + vec![], + vec![""], + vec!["9ex"], + vec!["_ex", ""], + vec!["_ex", "a b"], + vec!["_ex", "a;b"], + vec!["_ex", "a=b"], + vec!["_ex", "a\"b"], + vec!["_ex", "a/b"], + ] { + assert!(PublicPrefix::new(parts.clone()).is_none(), "{parts:?}"); + } + } + + #[test] + fn a_prefix_owns_the_cookie_names_it_gives_and_no_others() { + let prefix = TrustedServerPrefix::public(); + + for owned in ["ts-ec", "TS-EIDS", "ts-anything", "ts-"] { + assert!(prefix.owns_cookie_name(owned.as_bytes()), "{owned}"); + } + for other in ["ts", "tsx-ec", "_ex-ec", "sharedId"] { + assert!(!prefix.owns_cookie_name(other.as_bytes()), "{other}"); + } + } + + #[test] + fn the_default_carries_no_publisher_domain() { + let prefixes = Prefixes::default(); + + assert_eq!(prefixes.public().as_str(), "ts"); + assert_eq!(prefixes.server_key().domain(), ""); + } +} diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index dbf0d824a1..29b4312eb4 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -27,8 +27,7 @@ use super::{Contribution, RobotsTxtContributor}; use crate::error::TrustedServerError; use crate::module_context::{ModuleContext, ResolvedRequest}; use crate::platform::{ - CacheLifetimes, CacheSpec, KvError, LoadError, LoadedValue, Loader, RuntimeServices, - ServerKeyPrefix, StoreKey, + CacheLifetimes, CacheSpec, KvError, LoadError, LoadedValue, Loader, RuntimeServices, StoreKey, }; use crate::settings::Settings; @@ -79,10 +78,10 @@ pub(crate) async fn contribution_for( services: &RuntimeServices, settings: &Settings, ) -> Result> { - let prefix = ServerKeyPrefix::for_settings(settings); + let prefix = services.server_key_prefix(); let held = held_key(id, &contributor.fingerprint()); - let key = StoreKey::new(&prefix, CACHE, held.clone()); - let kept = StoreKey::new(&prefix, KEPT, held); + let key = StoreKey::new(prefix, CACHE, held.clone()); + let kept = StoreKey::new(prefix, KEPT, held); let ask = Ask { id, contributor: Arc::clone(contributor), @@ -215,6 +214,7 @@ pub(crate) mod tests { use crate::evidence::RequestInfo; use crate::module_context::{ModuleCall, ModuleRequest}; use crate::platform::test_support::{MemoryLoadingCache, StubHttpClient}; + use crate::prefix::test_support::{assert_keys_follow_the_publisher, trusted_server_for}; /// A contributor whose answers a test decides, counting how often it is /// asked. @@ -429,7 +429,8 @@ pub(crate) mod tests { } /// Services keeping answers in `cache` and kept answers in `store`, as one - /// instance of a host with no value cache. + /// instance of a host with no value cache, for the publisher [`settings`] + /// describes. pub(crate) fn services_with( cache: Arc, store: Arc, @@ -439,6 +440,7 @@ pub(crate) mod tests { )) .with_kv_store(store) .with_loading_cache(cache) + .with_prefixes(trusted_server_for("www.publisher.example")) } pub(crate) fn settings() -> Settings { @@ -492,8 +494,6 @@ pub(crate) mod tests { ])); let cache = Arc::new(MemoryLoadingCache::default()); let services = services_with(Arc::clone(&cache), Arc::default()); - let mut other = settings(); - other.publisher.domain = "other.example".to_owned(); contribution_for( "a", @@ -504,9 +504,15 @@ pub(crate) mod tests { ) .await .expect("should answer the first publisher"); - let answer = contribution_for("a", &contributor(&scripted), &request(), &services, &other) - .await - .expect("should answer the second publisher"); + let answer = contribution_for( + "a", + &contributor(&scripted), + &request(), + &services.with_prefixes(trusted_server_for("other.example")), + &settings(), + ) + .await + .expect("should answer the second publisher"); assert_eq!(answer, rules("User-Agent: OtherBot\nDisallow: /")); assert_eq!(scripted.times_asked(), 2, "each publisher is asked for"); @@ -521,6 +527,37 @@ pub(crate) mod tests { ); } + /// Both the held answer and the one kept for a failure are keyed by the + /// publisher the request's services carry. + #[tokio::test] + async fn the_held_and_kept_keys_follow_the_publisher() { + let keys_for = |domain: &str| { + let scripted = Arc::new(Scripted::new(vec![Ok(rules("User-Agent: *\nAllow: /"))])); + let cache = Arc::new(MemoryLoadingCache::default()); + let store = Arc::new(MemoryKvStore::default()); + let services = services_with(Arc::clone(&cache), Arc::clone(&store)) + .with_prefixes(trusted_server_for(domain)); + futures::executor::block_on(contribution_for( + "a", + &contributor(&scripted), + &request(), + &services, + &settings(), + )) + .expect("should ask the contributor"); + let kept: Vec = store + .entries + .lock() + .expect("should lock the test store") + .keys() + .cloned() + .collect(); + format!("{:?} {kept:?}", cache.keys()) + }; + + assert_keys_follow_the_publisher(keys_for); + } + #[tokio::test] async fn changed_settings_are_not_answered_from_the_old_copy() { let scripted = Arc::new(Scripted::new(vec![ @@ -764,6 +801,7 @@ pub(crate) mod tests { MemoryLoadingCache::default(), )))) .client_info(crate::platform::ClientInfo::default()) + .prefixes(trusted_server_for("www.publisher.example")) .build(); contribution_for( diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 19e82c4edc..fedae24986 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1445,6 +1445,24 @@ impl<'de> Deserialize<'de> for PermissionSignalConfig { } } +/// Which prefix module gives the start of every name the browser holds and +/// every key in a shared store. +/// +/// Mapped from the `[prefix]` TOML section. See [`crate::prefix`]. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PrefixConfig { + /// The prefix module to run, named as any module is, by its crate folder. + /// + /// Core's own module runs when this is absent, giving `ts` for the names + /// the browser holds and `ts.` for shared store keys, as + /// Trusted Server always has. A name no module supplies is refused when the + /// registry is built, in + /// [`IntegrationRegistry::with_plan_and_registrations`](crate::integrations::IntegrationRegistry::with_plan_and_registrations). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module: Option, +} + /// Geo / IP intelligence configuration. /// /// Mapped from the `[geo]` TOML section. Selects which module resolves a @@ -3699,6 +3717,10 @@ fn is_default_geo_config(value: &GeoConfig) -> bool { *value == GeoConfig::default() } +fn is_default_prefix_config(value: &PrefixConfig) -> bool { + *value == PrefixConfig::default() +} + fn is_default_permission_signal_config(value: &PermissionSignalConfig) -> bool { *value == PermissionSignalConfig::default() } @@ -4180,6 +4202,10 @@ pub struct Settings { #[serde(default, skip_serializing_if = "is_default_geo_config")] #[validate(nested)] pub geo: GeoConfig, + /// The prefix module, `[prefix] module`, see [`crate::prefix`]. + #[serde(default, skip_serializing_if = "is_default_prefix_config")] + #[validate(nested)] + pub prefix: PrefixConfig, #[serde( rename = "permission-signal", default, From 16837571a8c7ae2dc6ce53a14c96288dc50eeee4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 5 Oct 2026 23:00:53 +0100 Subject: [PATCH 02/15] Every cookie and storage entry is named after the public prefix The cookie constants lose their fixed `ts-` head and name the entry alone, `ec`, `ecr`, `ec-pull-complete`, `eids` and `tester`, and every read and write names the cookie through the request's public prefix, so a deployment whose module gives `_ex` reads and writes `_ex-ec`. The Edge Cookie state carries the prefix it was read under, and a module's response header naming a cookie under that prefix is refused as a cookie under `ts-` was. The page's bundle tag tells the client script the prefix, as `data-ts-public-prefix`, so the script names its own cookies after it and holds no copy. The template fingerprint includes the prefix, so a page stored under one is never served under another, and a key cookie naming one of the request's own identity cookies shares no template. --- crates/trusted-server-adapter-axum/src/app.rs | 4 +- .../src/app.rs | 4 +- .../trusted-server-adapter-fastly/src/app.rs | 25 ++- crates/trusted-server-adapter-spin/src/app.rs | 14 +- .../benches/html_processor_bench.rs | 1 + .../src/auction/endpoints.rs | 10 +- .../trusted-server-core/src/auction/types.rs | 2 +- crates/trusted-server-core/src/constants.rs | 16 +- .../src/cookies/template_cache_policy.rs | 26 ++- crates/trusted-server-core/src/ec/cookies.rs | 104 +++++++++--- crates/trusted-server-core/src/ec/finalize.rs | 31 ++-- crates/trusted-server-core/src/ec/mod.rs | 91 ++++++++-- crates/trusted-server-core/src/ec/module.rs | 45 +++-- .../src/ec/pull_sync_marker.rs | 57 ++++--- crates/trusted-server-core/src/ec/resolve.rs | 14 +- crates/trusted-server-core/src/edge_cookie.rs | 50 +++--- .../trusted-server-core/src/html_processor.rs | 65 ++++++- .../trusted-server-core/src/inspect/data.rs | 22 ++- .../src/integrations/js_asset_proxy.rs | 1 + .../src/integrations/registry.rs | 5 +- crates/trusted-server-core/src/prefix.rs | 15 ++ crates/trusted-server-core/src/publisher.rs | 160 ++++++++++++++++-- .../trusted-server-core/src/tester_cookie.rs | 71 +++++--- crates/trusted-server-core/src/tsjs.rs | 2 +- .../src/integrations/ec_client_fixed/index.ts | 23 ++- .../lib/src/integrations/prebid/index.ts | 35 ++-- .../lib/src/shared/public_prefix.ts | 44 +++++ .../ec_client_fixed/index.test.ts | 52 +++++- .../test/integrations/prebid/index.test.ts | 47 +++++ .../lib/test/shared/public_prefix.test.ts | 46 +++++ 30 files changed, 859 insertions(+), 223 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/shared/public_prefix.ts create mode 100644 crates/trusted-server-js/lib/test/shared/public_prefix.test.ts diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 390f54a8a1..d251063de0 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -576,7 +576,9 @@ fn named_route_handler( handle_permissions(&state.settings, &services, &req).await } NamedRouteHandler::Config => Ok(handle_config(&state.settings, &req)), - NamedRouteHandler::Data => handle_data(None, None, &req), + NamedRouteHandler::Data => { + handle_data(None, None, services.public_prefix(), &req) + } NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 59dda3db98..357c0c5e52 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -878,8 +878,8 @@ fn build_router(state: &Arc) -> RouterService { // What is held against the request's own Edge Cookie. This adapter // keeps no identity graph, so the page says nothing is held. - let data = make_handler(Arc::clone(&state), |_s, _services, req| async move { - handle_data(None, None, &req) + let data = make_handler(Arc::clone(&state), |_s, services, req| async move { + handle_data(None, None, services.public_prefix(), &req) }); router = router.route(DATA_PAGE_PATH, Method::GET, data); diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index bc4dc52451..2b73f66c9a 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -104,7 +104,7 @@ use trusted_server_core::auction::{ use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::closed_paths::closed_path_response; use trusted_server_core::config_payload::DEFAULT_SECRET_STORE_ID; -use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; +use trusted_server_core::constants::{COOKIE_EIDS, COOKIE_SHAREDID}; use trusted_server_core::ec::batch_sync::handle_batch_sync; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; @@ -650,7 +650,8 @@ async fn build_ec_request_state( ); } - let eids_cookie = crate::extract_cookie_value(req, COOKIE_TS_EIDS); + let eids_cookie = + crate::extract_cookie_value(req, &services.public_prefix().cookie_name(COOKIE_EIDS)); let sharedid_cookie = crate::extract_cookie_value(req, COOKIE_SHAREDID); let (ec_context, setup_error) = @@ -810,7 +811,14 @@ async fn execute_named( if matches!(handler, NamedRouteHandler::Data) { let kv = crate::maybe_identity_graph(&state.settings); let response = request_module(&state.settings.ec, &services) - .and_then(|module| handle_data(kv.as_ref(), module.as_deref(), &req)) + .and_then(|module| { + handle_data( + kv.as_ref(), + module.as_deref(), + services.public_prefix(), + &req, + ) + }) .unwrap_or_else(|error| http_error(&error)); return Ok(response); } @@ -885,8 +893,12 @@ async fn run_named_route( ) } } - NamedRouteHandler::SetTester => handle_set_tester(&state.settings), - NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::SetTester => { + handle_set_tester(&state.settings, services.public_prefix()) + } + NamedRouteHandler::ClearTester => { + handle_clear_tester(&state.settings, services.public_prefix()) + } NamedRouteHandler::Permissions => { unreachable!("the permissions are answered before EC setup") } @@ -977,7 +989,8 @@ async fn run_named_route( fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> Response { let device_signals = device_signals_for(&req); let is_real_browser = device_signals.looks_like_browser; - let eids_cookie = crate::extract_cookie_value(&req, COOKIE_TS_EIDS); + let eids_cookie = + crate::extract_cookie_value(&req, &services.public_prefix().cookie_name(COOKIE_EIDS)); let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); let result = crate::require_identity_graph(&state.settings).and_then(|kv| { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 9b4ae60e60..4344000f92 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -787,10 +787,16 @@ fn build_router(state: &Arc) -> RouterService { // /_ts/data, what is held against the request's own Edge Cookie. This // adapter keeps no identity graph, so the page says nothing is held. - let data_handler = move |ctx: RequestContext| async move { - Ok::( - handle_data(None, None, &ctx.into_request()).unwrap_or_else(|e| http_error(&e)), - ) + let s = Arc::clone(&state); + let data_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = s.services_for_request(&ctx); + Ok::( + handle_data(None, None, services.public_prefix(), &ctx.into_request()) + .unwrap_or_else(|e| http_error(&e)), + ) + } }; // /verify-signature diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 27e0496722..e650dc0782 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -8,6 +8,7 @@ use trusted_server_core::streaming_processor::StreamProcessor as _; fn make_config() -> HtmlProcessorConfig { HtmlProcessorConfig { csp_nonce_observed: None, + public_prefix: None, origin_host: "origin.bench.example.com".to_string(), request_host: "proxy.bench.example.com".to_string(), request_scheme: "https".to_string(), diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index b1a56c8e46..35ec4fe75a 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -10,7 +10,7 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; -use crate::constants::COOKIE_TS_EIDS; +use crate::constants::COOKIE_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; use crate::ec::EcKvSnapshot; @@ -295,7 +295,11 @@ pub async fn handle_auction( let client_eids = if ec_id.is_some() { resolve_client_auction_eids( body.eids.as_ref(), - extract_cookie_value(&http_req, COOKIE_TS_EIDS).as_deref(), + extract_cookie_value( + &http_req, + &services.public_prefix().cookie_name(COOKIE_EIDS), + ) + .as_deref(), ) } else { None @@ -1370,7 +1374,7 @@ mod tests { let req = Request::builder() .method("POST") .uri("https://test-publisher.com/auction") - .header("cookie", format!("{COOKIE_TS_EIDS}={encoded_cookie}")) + .header("cookie", format!("ts-{COOKIE_EIDS}={encoded_cookie}")) .body(EdgeBody::from( serde_json::to_vec(&body).expect("should serialize body"), )) diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index db7818b4d8..7e500f263e 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -96,7 +96,7 @@ pub struct UserInfo { /// cookies/headers, not from stored data. #[serde(skip)] pub consent: Option, - /// Extended User IDs parsed from the [`crate::constants::COOKIE_TS_EIDS`] cookie. + /// Extended User IDs parsed from the [`crate::constants::COOKIE_EIDS`] cookie. /// /// Raw (un-gated) values from the browser; consent gating via /// [`crate::consent::gate_eids_by_consent`] is applied centrally in the diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index bbb1f19f65..58728220e9 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -1,18 +1,22 @@ use http::header::HeaderName; -pub const COOKIE_TS_EC: &str = "ts-ec"; -/// Non-`HttpOnly` companion to [`COOKIE_TS_EC`], set when a client-cycle +/// The Edge Cookie, named `-ec`, such as `ts-ec`. Each name +/// here is written after the deployment's public prefix with +/// [`PublicPrefix::cookie_name`](crate::prefix::PublicPrefix::cookie_name). +pub const COOKIE_EC: &str = "ec"; +/// Non-`HttpOnly` companion to [`COOKIE_EC`], set when a client-cycle /// resolve succeeds. It carries no identity (the value is `1`); it only lets /// the page script see that an Edge Cookie exists, which the `HttpOnly` cookie /// itself cannot, so the script does not re-post on every page view. -pub const COOKIE_TS_EC_RESOLVED: &str = "ts-ecr"; +pub const COOKIE_EC_RESOLVED: &str = "ecr"; /// Short-lived signed proof that the current EC row has every pull-partner UID. -pub const COOKIE_TS_EC_PULL_COMPLETE: &str = "ts-ec-pull-complete"; +pub const COOKIE_EC_PULL_COMPLETE: &str = "ec-pull-complete"; /// Cookie written by the Trusted Server JS SDK containing a standard-base64-encoded /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. -pub const COOKIE_TS_EIDS: &str = "ts-eids"; -pub const COOKIE_TS_TESTER: &str = "ts-tester"; +pub const COOKIE_EIDS: &str = "eids"; +/// The cookie the tester routes set and clear. +pub const COOKIE_TESTER: &str = "tester"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); diff --git a/crates/trusted-server-core/src/cookies/template_cache_policy.rs b/crates/trusted-server-core/src/cookies/template_cache_policy.rs index 61c3b9139c..71d9318a50 100644 --- a/crates/trusted-server-core/src/cookies/template_cache_policy.rs +++ b/crates/trusted-server-core/src/cookies/template_cache_policy.rs @@ -2,8 +2,20 @@ use std::collections::{HashMap, HashSet}; -use crate::constants::{COOKIE_SHAREDID, COOKIE_TS_EC, COOKIE_TS_EIDS}; +use crate::constants::{COOKIE_EC, COOKIE_EIDS, COOKIE_SHAREDID}; use crate::platform::TemplateCookieValue; +use crate::prefix::PublicPrefix; + +/// The identity cookies a template key must never be built from, under +/// `public`, the request's public prefix, being the Edge Cookie, the EIDs +/// cookie and `sharedId`. +pub(crate) fn identity_cookies(public: &PublicPrefix) -> [String; 3] { + [ + public.cookie_name(COOKIE_EC), + public.cookie_name(COOKIE_EIDS), + COOKIE_SHAREDID.to_owned(), + ] +} /// Whether the request can share a template and its explicit cookie dimensions. #[derive(Debug, Eq, PartialEq)] @@ -17,11 +29,14 @@ pub(crate) enum TemplateCookieDecision { /// # Errors /// /// Returns an error naming the field and cookie when a name is invalid or repeated, -/// or when the key list names a Trusted Server identity cookie. +/// or when the key list names an identity cookie under core's own prefix. A +/// deployment's own prefix is checked on each request, see +/// [`identity_cookies`]. pub(crate) fn validate_cookie_names( key_names: &[String], bypass_names: &[String], ) -> Result<(), String> { + let identity = identity_cookies(&PublicPrefix::default()); let mut seen = HashSet::new(); for (field, names) in [ ("template_cache_key_cookies", key_names), @@ -31,9 +46,7 @@ pub(crate) fn validate_cookie_names( if !is_cookie_name(name.as_bytes()) { return Err(format!("{field} contains invalid cookie name `{name}`")); } - if field == "template_cache_key_cookies" - && [COOKIE_TS_EC, COOKIE_TS_EIDS, COOKIE_SHAREDID].contains(&name.as_str()) - { + if field == "template_cache_key_cookies" && identity.contains(name) { return Err(format!( "{field} must not key on Trusted Server identity cookie `{name}`" )); @@ -232,7 +245,8 @@ mod tests { #[test] fn template_cookie_policy_rejects_identity_keys_but_allows_bypass() { - for name in [COOKIE_TS_EC, COOKIE_TS_EIDS, COOKIE_SHAREDID] { + for name in identity_cookies(&PublicPrefix::default()) { + let name = name.as_str(); let error = validate_cookie_names(&names(&[name]), &[]) .expect_err("should reject identity cookie keys"); assert!( diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ead32f967e..ac67fbbe07 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -1,6 +1,7 @@ //! EC cookie creation and expiration helpers. //! -//! These functions handle the `Set-Cookie` header for the `ts-ec` cookie. +//! These functions handle the `Set-Cookie` header for the Edge Cookie, named +//! `-ec`, such as `ts-ec`. //! Cookie attributes follow current best practices: //! //! - `Domain` is computed as `.{publisher.domain}` for subdomain coverage @@ -16,7 +17,8 @@ use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; -use crate::constants::{COOKIE_TS_EC, COOKIE_TS_EC_RESOLVED}; +use crate::constants::{COOKIE_EC, COOKIE_EC_RESOLVED}; +use crate::prefix::PublicPrefix; use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). @@ -49,9 +51,10 @@ pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { /// /// Centralises the cookie attribute string so that changes to security /// attributes (e.g. adding `Partitioned`) only need updating in one place. -fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { +fn format_set_cookie(public: &PublicPrefix, domain: &str, value: &str, max_age: i32) -> String { format!( - "{COOKIE_TS_EC}={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), ) } @@ -64,8 +67,9 @@ fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { /// an identifier is rejected outright rather than rewritten, so the cookie /// value and the identity-graph key can never silently diverge. #[must_use] -pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { +pub(crate) fn create_ec_cookie(settings: &Settings, public: &PublicPrefix, ec_id: &str) -> String { format_set_cookie( + public, &settings.publisher.ec_cookie_domain(), ec_id, COOKIE_MAX_AGE, @@ -82,7 +86,12 @@ pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { /// controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { +pub fn set_ec_cookie( + settings: &Settings, + public: &PublicPrefix, + response: &mut Response, + ec_id: &str, +) { if !ec_id_has_only_allowed_chars(ec_id) { log::error!( "Rejecting EC ID for Set-Cookie: value of {} bytes is empty, over {} bytes, or \ @@ -93,7 +102,7 @@ pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_ return; } - match HeaderValue::from_str(&create_ec_cookie(settings, ec_id)) { + match HeaderValue::from_str(&create_ec_cookie(settings, public, ec_id)) { Ok(val) => { response.headers_mut().append(header::SET_COOKIE, val); } @@ -110,8 +119,13 @@ pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_ /// /// Used when a user revokes consent — the browser will delete the cookie /// on receipt of this header. -pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) { +pub fn expire_ec_cookie( + settings: &Settings, + public: &PublicPrefix, + response: &mut Response, +) { match HeaderValue::from_str(&format_set_cookie( + public, &settings.publisher.ec_cookie_domain(), "", 0, @@ -127,7 +141,7 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) } // Expire the resolved marker together with the Edge Cookie, so a page // whose visitor later re-establishes the permission can resolve again. - expire_ec_resolved_marker(settings, response); + expire_ec_resolved_marker(settings, public, response); } /// Expires the resolved marker on its own, leaving the Edge Cookie alone. @@ -141,9 +155,14 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) /// rather than a restarted one. Expiring the marker whenever the incoming /// value is not one the selected module owns restarts the cycle without /// depending on any vendor page script comparing a marker value correctly. -pub fn expire_ec_resolved_marker(settings: &Settings, response: &mut Response) { +pub fn expire_ec_resolved_marker( + settings: &Settings, + public: &PublicPrefix, + response: &mut Response, +) { let marker = format!( - "{COOKIE_TS_EC_RESOLVED}=; 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(), ); match HeaderValue::from_str(&marker) { @@ -165,9 +184,14 @@ pub fn expire_ec_resolved_marker(settings: &Settings, response: &mut Response) { +pub(crate) fn set_resolved_marker_cookie( + settings: &Settings, + public: &PublicPrefix, + response: &mut Response, +) { let cookie = format!( - "{COOKIE_TS_EC_RESOLVED}=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(), ); match HeaderValue::from_str(&cookie) { @@ -230,6 +254,30 @@ mod tests { .expect("should build test response") } + /// Every cookie this file writes takes the request's public prefix, so a + /// deployment's own names replace `ts` throughout. + #[test] + fn each_cookie_is_named_after_the_public_prefix() { + let settings = create_test_settings(); + let public = PublicPrefix::new(["_ex"]).expect("should accept the prefix"); + 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); + + let names: Vec = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + let text = value.to_str().expect("should be valid UTF-8"); + text.split('=').next().unwrap_or_default().to_owned() + }) + .collect(); + assert_eq!(names, ["_ex-ec", "_ex-ecr", "_ex-ec", "_ex-ecr"]); + } + /// A valid EC ID for use in cookie tests. const TEST_EC_ID: &str = "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffff0000000011111111.abcXYZ"; @@ -237,13 +285,13 @@ mod tests { #[test] fn create_ec_cookie_uses_computed_domain() { let settings = create_test_settings(); - let result = create_ec_cookie(&settings, TEST_EC_ID); + let result = create_ec_cookie(&settings, &PublicPrefix::default(), TEST_EC_ID); assert_eq!( result, format!( - "{}={}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly", - COOKIE_TS_EC, TEST_EC_ID, settings.publisher.domain, COOKIE_MAX_AGE, + "ts-ec={}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly", + TEST_EC_ID, settings.publisher.domain, COOKIE_MAX_AGE, ), "should use computed cookie domain (.{{domain}})" ); @@ -267,7 +315,7 @@ mod tests { ] { let mut response = empty_response(); - set_ec_cookie(&settings, &mut response, value); + set_ec_cookie(&settings, &PublicPrefix::default(), &mut response, value); let cookie = response .headers() @@ -278,12 +326,16 @@ mod tests { assert_eq!( cookie, format!( - "{}={value}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly", - COOKIE_TS_EC, settings.publisher.domain, COOKIE_MAX_AGE, + "ts-ec={value}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly", + settings.publisher.domain, COOKIE_MAX_AGE, ), "{case}" ); - assert_eq!(cookie, create_ec_cookie(&settings, value), "{case}"); + assert_eq!( + cookie, + create_ec_cookie(&settings, &PublicPrefix::default(), value), + "{case}" + ); } } @@ -304,7 +356,7 @@ mod tests { ] { let mut response = empty_response(); - set_ec_cookie(&settings, &mut response, value); + set_ec_cookie(&settings, &PublicPrefix::default(), &mut response, value); assert!( response.headers().get(header::SET_COOKIE).is_none(), @@ -317,7 +369,7 @@ mod tests { fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); let mut response = empty_response(); - expire_ec_cookie(&settings, &mut response); + expire_ec_cookie(&settings, &PublicPrefix::default(), &mut response); let cookie_header = response .headers() @@ -330,7 +382,7 @@ mod tests { "should set Max-Age=0 to expire cookie" ); assert!( - cookie_str.starts_with(&format!("{COOKIE_TS_EC}=;")), + cookie_str.starts_with("ts-ec=;"), "should clear cookie value" ); assert!( @@ -343,7 +395,7 @@ mod tests { fn expire_ec_cookie_matches_security_attributes() { let settings = create_test_settings(); let mut response = empty_response(); - expire_ec_cookie(&settings, &mut response); + expire_ec_cookie(&settings, &PublicPrefix::default(), &mut response); let cookie_header = response .headers() @@ -354,8 +406,8 @@ mod tests { assert_eq!( cookie_str, format!( - "{}=; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age=0; HttpOnly", - COOKIE_TS_EC, settings.publisher.domain, + "ts-ec=; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age=0; HttpOnly", + settings.publisher.domain, ), "expiry cookie should retain the same security attributes as the live cookie" ); diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index edf8a41682..603243ef1c 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -37,7 +37,7 @@ use super::{EcKvSnapshot, EidSyncSource, current_timestamp, log_id}; /// leaves an already-issued cookie intact. There is no separate consent KV /// store to clean up. /// -/// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted +/// `eids_cookie` should be the raw value of the EIDs cookie extracted /// from the request *before* routing consumes it. /// /// `services` are the request's runtime services, handed to the selected Edge @@ -61,7 +61,7 @@ pub async fn ec_finalize_response( // unless a module produced headers, so it is safe on every path. Each // one was checked against core's reserved response surface at capture // time in `EcContext::candidate_id`, so nothing here can set a - // managed `ts-` cookie, an `x-ts-` header, or a framing or hop-by-hop + // cookie under the public prefix, an `x-ts-` header, or a framing or hop-by-hop // header. They accumulate with whatever the origin returned rather than // replacing it, for the reasons on // `module::apply_module_response_headers`. @@ -80,7 +80,8 @@ pub async fn ec_finalize_response( // Expire the request-local marker independently of the EC cookie: a // withdrawal must stop any pending pull-sync disclosure window. if storage_withdrawn && ec_context.pull_sync_marker().was_present() { - expire_marker(ec_context.pull_sync_marker_mut(), response); + let public = ec_context.public_prefix().clone(); + expire_marker(&public, ec_context.pull_sync_marker_mut(), response); } finalize_unusable_consent( @@ -94,7 +95,7 @@ pub async fn ec_finalize_response( return; } - // The request carried a `ts-ec` the selected module does not own, which + // The request carried an Edge Cookie the selected module does not own, which // is what a switch between client-cycle modules looks like on the first // request after the switch. The resolved marker is not namespaced by the // module code, so it would otherwise survive and tell the new module's @@ -104,7 +105,7 @@ pub async fn ec_finalize_response( // already ignored for read-back and a returning visitor may still be // carrying an identifier another selected module would own. if ec_context.cookie_was_present() && !ec_context.ec_was_present() { - expire_ec_resolved_marker(settings, response); + expire_ec_resolved_marker(settings, ec_context.public_prefix(), response); } // Returning user: EC is permitted and came from the request. @@ -237,8 +238,10 @@ fn reconcile_pull_sync_marker( ) { let ec_id = ec_context.ec_value().map(str::to_owned); let snapshot = ec_context.kv_snapshot().clone(); + let public = ec_context.public_prefix().clone(); reconcile_marker( settings, + &public, registry, ec_id.as_deref(), &snapshot, @@ -411,7 +414,7 @@ pub fn set_ec_cookie_on_response( response: &mut Response, ) { if let Some(ec_id) = ec_context.ec_value() { - set_ec_cookie(settings, response, ec_id); + set_ec_cookie(settings, ec_context.public_prefix(), response, ec_id); } } @@ -444,8 +447,12 @@ fn partner_response_header(source_domain: &str) -> String { /// Clears EC cookie and removes EC-specific response headers. /// /// Used when the request carries an explicit withdrawal signal. -pub fn clear_ec_on_response(settings: &Settings, response: &mut Response) { - expire_ec_cookie(settings, response); +pub fn clear_ec_on_response( + settings: &Settings, + public: &crate::prefix::PublicPrefix, + response: &mut Response, +) { + expire_ec_cookie(settings, public, response); clear_ec_headers_on_response(response, None); } @@ -470,7 +477,7 @@ fn finalize_unusable_consent( return; } - expire_ec_cookie(settings, response); + expire_ec_cookie(settings, ec_context.public_prefix(), response); // Compute once for the authoritative identity-graph tombstones, keyed by // the canonical form each row is stored under. @@ -747,7 +754,11 @@ mod tests { set_header(&mut response, "x-ts-eids", "[]"); set_header(&mut response, "x-ts-unrelated", "keep-me"); - clear_ec_on_response(&settings, &mut response); + clear_ec_on_response( + &settings, + &crate::prefix::PublicPrefix::default(), + &mut response, + ); assert!( get_header(&response, "x-ts-ec").is_none(), diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 7ef3ccb5b9..ddff59b954 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -80,7 +80,7 @@ use http::Request; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::{self as consent_mod, ConsentContext, ConsentPipelineInput}; -use crate::constants::{COOKIE_TS_EC, COOKIE_TS_EC_PULL_COMPLETE}; +use crate::constants::{COOKIE_EC, COOKIE_EC_PULL_COMPLETE}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; @@ -89,6 +89,7 @@ use crate::geo::GeoInfo; use crate::module_context::{ModuleContext, ModuleRequest, ResolvedRequest}; use crate::permissions::{Permission, PermissionState}; use crate::platform::RuntimeServices; +use crate::prefix::PublicPrefix; use crate::settings::Settings; use device::DeviceSignals; use module::{EdgeCookieModule, GeneratedEdgeCookie}; @@ -190,7 +191,7 @@ pub use generation::{ /// Parsed EC identity from an incoming request. struct RequestEc { - /// EC ID from the `ts-ec` cookie, if present. + /// EC ID from the Edge Cookie, `-ec`, if present. cookie_ec: Option, /// Pull-sync completeness marker, if present. pull_sync_marker: Option, @@ -203,16 +204,19 @@ struct RequestEc { /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -fn parse_ec_from_request(req: &Request) -> Result> { +fn parse_ec_from_request( + req: &Request, + public: &PublicPrefix, +) -> Result> { let jar = handle_request_cookies(req)?; let cookie_ec = jar .as_ref() - .and_then(|j| j.get(COOKIE_TS_EC)) + .and_then(|j| j.get(&public.cookie_name(COOKIE_EC))) .map(cookie::Cookie::value) - .and_then(|value| request_ec_id_if_allowed(value, "ts-ec cookie")); + .and_then(|value| request_ec_id_if_allowed(value, "Edge Cookie")); let pull_sync_marker = jar .as_ref() - .and_then(|j| j.get(COOKIE_TS_EC_PULL_COMPLETE)) + .and_then(|j| j.get(&public.cookie_name(COOKIE_EC_PULL_COMPLETE))) .map(cookie::Cookie::value) .map(str::to_owned); @@ -232,7 +236,8 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { None } -/// The Edge Cookie identifier `req` carries in its `ts-ec` cookie, or `None` +/// The Edge Cookie identifier `req` carries in its Edge Cookie under +/// `public`, the request's public prefix, or `None` /// when it carries none, or one holding a character no identifier has. /// /// # Errors @@ -240,8 +245,9 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails pub(crate) fn request_cookie_ec( req: &Request, + public: &PublicPrefix, ) -> Result, Report> { - Ok(parse_ec_from_request(req)?.cookie_ec) + Ok(parse_ec_from_request(req, public)?.cookie_ec) } /// Captures the EC state for a single request lifecycle. @@ -253,7 +259,7 @@ pub(crate) fn request_cookie_ec( pub struct EcContext { /// The EC ID value, if one exists (from request) or was generated. ec_value: Option, - /// The EC ID from the `ts-ec` cookie, if present on the incoming + /// The EC ID from the Edge Cookie, if present on the incoming /// request. Stored separately from `ec_value` because the header may /// take precedence, but revocation still needs the cookie value. cookie_ec_value: Option, @@ -307,6 +313,9 @@ pub struct EcContext { kv_snapshot: EcKvSnapshot, /// Whether this request may rotate an orphaned EC identity. recovery_eligible: bool, + /// The start of every cookie name the request's Edge Cookie state reads + /// and writes, from the request's services. + public_prefix: PublicPrefix, /// Browser-carried proof of recent pull-partner completeness. pull_sync_marker: PullSyncMarkerState, /// Allowed returning-user EID persistence source, assigned only after request filters pass. @@ -317,7 +326,7 @@ impl EcContext { /// Reads EC state from an incoming request without generating a new ID. /// /// This is the first phase of the EC lifecycle. It: - /// - Checks the `ts-ec` cookie for an existing EC ID + /// - Checks the Edge Cookie, `-ec`, for an existing EC ID /// - Captures the client IP (normalized) for later generation /// - Builds the full [`ConsentContext`] from cookies, headers, and geo /// @@ -404,7 +413,7 @@ impl EcContext { geo_status: consent::GeoStatus<'_>, ) -> Result> { let geo_info = geo_status.info(); - let parsed = parse_ec_from_request(req)?; + let parsed = parse_ec_from_request(req, services.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 @@ -539,6 +548,7 @@ impl EcContext { response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + public_prefix: services.public_prefix().clone(), pull_sync_marker: PullSyncMarkerState::from_cookie(parsed.pull_sync_marker), eid_sync_source: None, }) @@ -663,7 +673,7 @@ impl EcContext { // reserved surface before any of them are kept. A module may set its // own cookies and headers, but not a managed `ts-` cookie, a header in // the `x-ts-` namespace, or a framing or hop-by-hop header. Without the - // check a module could write `ts-ec` itself and bypass the identifier + // check a module could write the Edge Cookie itself and bypass the identifier // validation and identity-graph row generation enforces. A rejection // returns an error, as the identifier-bounds rejection below does. // Because the check runs before the headers are captured, nothing from @@ -671,7 +681,8 @@ impl EcContext { // without a new Edge Cookie. Checked before the identifier is read, // because a module can return headers with no identifier at all. for (name, value) in &generated.response_headers { - if let Some(effect) = module::reserved_response_effect(name, value) { + if let Some(effect) = module::reserved_response_effect(name, value, &self.public_prefix) + { return Err(Report::new(TrustedServerError::EdgeCookie { message: format!( "Module `{}` returned a response header `{name}` that {effect}", @@ -873,7 +884,7 @@ impl EcContext { self.ec_value().and_then(|value| self.kv_key_for(value)) } - /// The identity-graph key for the `ts-ec` cookie the request carried. + /// The identity-graph key for the Edge Cookie the request carried. /// /// Withdrawal tombstones the cookie's row as well as the active one, /// because a stateless deployment leaves [`ec_kv_key`](Self::ec_kv_key) @@ -895,13 +906,13 @@ impl EcContext { .and_then(|value| self.kv_key_for(value)) } - /// Returns whether the `ts-ec` cookie was present on the incoming request. + /// Returns whether the Edge Cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { self.cookie_ec_value.is_some() } - /// Returns whether an EC ID was found in the `ts-ec` cookie on the + /// Returns whether an EC ID was found in the Edge Cookie on the /// incoming request. #[must_use] pub fn ec_was_present(&self) -> bool { @@ -920,6 +931,13 @@ impl EcContext { &self.consent } + /// The start of every cookie name this request's Edge Cookie state reads + /// and writes, such as `ts` for `ts-ec`. + #[must_use] + pub fn public_prefix(&self) -> &PublicPrefix { + &self.public_prefix + } + /// Returns a mutable reference to the consent context. /// /// Allows handlers to apply query-param fallback consent for the current @@ -1189,6 +1207,7 @@ impl EcContext { response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + public_prefix: PublicPrefix::default(), pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } @@ -1234,6 +1253,7 @@ impl EcContext { response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + public_prefix: PublicPrefix::default(), pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } @@ -1268,6 +1288,7 @@ impl EcContext { response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + public_prefix: PublicPrefix::default(), pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } @@ -2898,6 +2919,44 @@ pub(crate) mod tests { assert!(ec.cookie_was_present(), "should still detect cookie"); } + /// The Edge Cookie is read under the request's public prefix alone, so a + /// cookie written under another prefix, core's own `ts` included, is + /// ignored, and the one written back takes the request's prefix. + #[test] + fn the_edge_cookie_is_read_and_written_under_the_requests_prefix() { + let settings = create_test_settings(); + let ec_id = valid_ec_id("d", "Pfx001"); + let prefixes = crate::prefix::Prefixes::new( + crate::prefix::PublicPrefix::new(["_ex"]).expect("should accept the prefix"), + crate::platform::ServerKeyPrefix::new("ex", "test-publisher.com"), + ); + let services = noop_services().with_prefixes(prefixes); + + let old = create_test_request(&[("cookie", &format!("ts-ec={ec_id}"))]); + let ignored = + EcContext::read_from_request(&settings, &old, &services).expect("should read"); + assert!( + ignored.ec_value().is_none() && !ignored.cookie_was_present(), + "a cookie under another prefix is ignored" + ); + + let new = create_test_request(&[("cookie", &format!("_ex-ec={ec_id}"))]); + let read = EcContext::read_from_request(&settings, &new, &services).expect("should read"); + assert_eq!(read.ec_value(), Some(ec_id.as_str())); + + let mut response = http::Response::new(edgezero_core::body::Body::empty()); + crate::ec::finalize::set_ec_cookie_on_response(&settings, &read, &mut response); + let written = response + .headers() + .get(http::header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .expect("should write the cookie"); + assert!( + written.starts_with(&format!("_ex-ec={ec_id};")), + "{written}" + ); + } + #[test] fn read_from_request_no_ec() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index b54f4af439..11fcf11bac 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -36,6 +36,7 @@ use crate::error::TrustedServerError; use crate::evidence::{HostSignals, RequestInfo}; use crate::module_context::ModuleCall; use crate::permissions::{Permission, PermissionSet}; +use crate::prefix::PublicPrefix; use crate::redacted::Redacted; use crate::settings::{Ec, EcModuleBlock}; @@ -186,17 +187,6 @@ pub struct GeneratedEdgeCookie { pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } -/// The cookie-name namespace Trusted Server manages. -/// -/// Every cookie core writes or reads as part of its own behavior is named -/// `ts-` (`ts-ec` in [`COOKIE_TS_EC`](crate::constants::COOKIE_TS_EC), -/// `ts-eids` in [`COOKIE_TS_EIDS`](crate::constants::COOKIE_TS_EIDS), and -/// `ts-tester` in [`COOKIE_TS_TESTER`](crate::constants::COOKIE_TS_TESTER)), so -/// core defends the whole prefix rather than a list that a new managed cookie -/// would silently outgrow. `sharedId` is deliberately not reserved: core only -/// reads it, and it belongs to the page's own identity stack. -const MANAGED_COOKIE_NAME_PREFIX: &[u8] = b"ts-"; - /// The response-header namespace Trusted Server reserves for itself. /// /// Covers the fixed EC output headers and the per-partner @@ -230,8 +220,9 @@ const FRAMING_OR_HOP_BY_HOP_HEADERS: &[&str] = &[ /// Why one module response header falls inside core's reserved surface. #[derive(Debug, Copy, Clone, Eq, PartialEq, derive_more::Display)] pub enum ReservedResponseEffect { - /// A `Set-Cookie` naming a cookie in the `ts-` namespace core manages. - #[display("sets a cookie in the `ts-` namespace Trusted Server manages")] + /// A `Set-Cookie` naming a cookie in the namespace the deployment's public + /// prefix starts, which core manages. + #[display("sets a cookie in the namespace the public prefix starts, which core manages")] ManagedCookie, /// A header in the `x-ts-` namespace core emits and strips. @@ -271,20 +262,24 @@ fn set_cookie_name(value: &[u8]) -> &[u8] { /// publisher proxy and integration proxy log the error and serve the response /// without an Edge Cookie, and orphan recovery in EC finalization leaves the /// visitor's existing cookie in place. Applying the header instead would let a -/// module set `ts-ec` directly, bypassing core's identifier validation and +/// module set the Edge Cookie directly, bypassing core's identifier validation and /// its requirement that a created identifier have an identity-graph row. +/// +/// `public` is the request's public prefix, and every cookie core writes or +/// reads as part of its own behavior is named `-`, +/// such as `ts-ec`, so core defends the whole namespace rather than a list +/// that a new managed cookie would silently outgrow. `sharedId` is deliberately +/// not reserved, because core only reads it and it belongs to the page's own +/// identity stack. #[must_use] pub fn reserved_response_effect( name: &http::HeaderName, value: &http::HeaderValue, + public: &PublicPrefix, ) -> Option { let lower = name.as_str(); if lower == http::header::SET_COOKIE.as_str() { - let cookie_name = set_cookie_name(value.as_bytes()); - if cookie_name.len() >= MANAGED_COOKIE_NAME_PREFIX.len() - && cookie_name[..MANAGED_COOKIE_NAME_PREFIX.len()] - .eq_ignore_ascii_case(MANAGED_COOKIE_NAME_PREFIX) - { + if public.owns_cookie_name(set_cookie_name(value.as_bytes())) { return Some(ReservedResponseEffect::ManagedCookie); } return None; @@ -1525,7 +1520,7 @@ mod tests { ] { let (name, value) = header(name, value); assert_eq!( - reserved_response_effect(&name, &value), + reserved_response_effect(&name, &value, &PublicPrefix::default()), Some(expected), "`{name}` should be reserved" ); @@ -1543,7 +1538,7 @@ mod tests { ] { let (name, value) = header(name, value); assert_eq!( - reserved_response_effect(&name, &value), + reserved_response_effect(&name, &value, &PublicPrefix::default()), None, "`{name}` is the module's own and should be allowed" ); @@ -1566,7 +1561,7 @@ mod tests { "the test value should not be readable as UTF-8" ); assert_eq!( - reserved_response_effect(&name, &value), + reserved_response_effect(&name, &value, &PublicPrefix::default()), Some(ReservedResponseEffect::ManagedCookie), "a non-UTF-8 Set-Cookie should still be matched on its cookie name" ); @@ -2244,10 +2239,10 @@ mod tests { ); assert!( script.contains(&format!( - "const MARKER_COOKIE_NAME = '{}'", - crate::constants::COOKIE_TS_EC_RESOLVED + "const MARKER_COOKIE = '{}'", + crate::constants::COOKIE_EC_RESOLVED )), - "the page script's marker cookie name should match COOKIE_TS_EC_RESOLVED" + "the page script's marker cookie name should match COOKIE_EC_RESOLVED" ); // The page module declares the same permission the server-side // module requires, and checks it against the state the page is 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 e23e8a81d7..446bd9ead9 100644 --- a/crates/trusted-server-core/src/ec/pull_sync_marker.rs +++ b/crates/trusted-server-core/src/ec/pull_sync_marker.rs @@ -7,7 +7,8 @@ use hmac::{Hmac, Mac as _}; use http::{HeaderValue, Response, header}; use sha2::{Digest as _, Sha256}; -use crate::constants::COOKIE_TS_EC_PULL_COMPLETE; +use crate::constants::COOKIE_EC_PULL_COMPLETE; +use crate::prefix::PublicPrefix; use crate::redacted::Redacted; use crate::settings::{EcModuleBlock, Settings}; @@ -107,6 +108,7 @@ pub(crate) fn validate_marker_state( /// Reconciles browser marker state against finalized authoritative KV state. pub(crate) fn reconcile_marker( settings: &Settings, + public: &PublicPrefix, registry: &PartnerRegistry, ec_id: Option<&str>, snapshot: &EcKvSnapshot, @@ -115,17 +117,17 @@ pub(crate) fn reconcile_marker( ) { let pull_partners = sorted_pull_partner_domains(registry); if pull_partners.is_empty() { - expire_if_present(state, response); + expire_if_present(public, state, response); return; } let Some(ec_id) = ec_id else { - expire_if_present(state, response); + expire_if_present(public, state, response); return; }; if !matches!(snapshot, EcKvSnapshot::NotRead) && !snapshot.belongs_to(ec_id) { - expire_if_present(state, response); + expire_if_present(public, state, response); return; } @@ -135,34 +137,39 @@ pub(crate) fn reconcile_marker( .entry_for(ec_id) .expect("snapshot binding should be checked before marker reconciliation"); if !entry.consent.ok { - expire_if_present(state, response); + expire_if_present(public, state, response); } else if entry_is_pull_complete(entry, registry) { if !state.is_valid() { - set_marker(settings, registry, ec_id, state, response); + set_marker(settings, public, registry, ec_id, state, response); } } else { - expire_if_present(state, response); + expire_if_present(public, state, response); } } EcKvSnapshot::Missing { .. } => { - expire_if_present(state, response); + expire_if_present(public, state, response); } EcKvSnapshot::Failed { .. } | EcKvSnapshot::NotRead => { if matches!(state, PullSyncMarkerState::Invalid) { - expire_if_present(state, response); + expire_if_present(public, state, response); } } } } /// Expires the completeness marker regardless of KV state. -pub(crate) fn expire_marker(state: &mut PullSyncMarkerState, response: &mut Response) { - append_cookie(response, &format_marker_cookie("", 0)); +pub(crate) fn expire_marker( + public: &PublicPrefix, + state: &mut PullSyncMarkerState, + response: &mut Response, +) { + append_cookie(response, &format_marker_cookie(public, "", 0)); *state = PullSyncMarkerState::Absent; } fn set_marker( settings: &Settings, + public: &PublicPrefix, registry: &PartnerRegistry, ec_id: &str, state: &mut PullSyncMarkerState, @@ -173,13 +180,20 @@ fn set_marker( let Some(value) = create_marker(settings, registry, ec_id, expires_at) else { return; }; - append_cookie(response, &format_marker_cookie(&value, MARKER_MAX_AGE_SECS)); + append_cookie( + response, + &format_marker_cookie(public, &value, MARKER_MAX_AGE_SECS), + ); *state = PullSyncMarkerState::Valid { expires_at }; } -fn expire_if_present(state: &mut PullSyncMarkerState, response: &mut Response) { +fn expire_if_present( + public: &PublicPrefix, + state: &mut PullSyncMarkerState, + response: &mut Response, +) { if state.was_present() { - expire_marker(state, response); + expire_marker(public, state, response); } } @@ -194,13 +208,12 @@ fn append_cookie(response: &mut Response, value: &str) { } } -fn format_marker_cookie(value: &str, max_age: u64) -> String { +fn format_marker_cookie(public: &PublicPrefix, value: &str, max_age: u64) -> String { // Keep this cookie host-only. The signed payload does not include the // request host, so a Domain attribute would allow replay across sibling // hosts that share the domain-wide EC cookie and passphrase. - format!( - "{COOKIE_TS_EC_PULL_COMPLETE}={value}; Path=/; Secure; SameSite=Lax; Max-Age={max_age}; HttpOnly" - ) + let name = public.cookie_name(COOKIE_EC_PULL_COMPLETE); + format!("{name}={value}; Path=/; Secure; SameSite=Lax; Max-Age={max_age}; HttpOnly") } #[cfg(test)] @@ -502,6 +515,7 @@ mod tests { reconcile_marker( &settings, + &PublicPrefix::default(), ®istry, Some(EC_ID), &snapshot, @@ -514,6 +528,7 @@ mod tests { let mut absent_response = empty_response(); reconcile_marker( &settings, + &PublicPrefix::default(), ®istry, Some(EC_ID), &snapshot, @@ -551,6 +566,7 @@ mod tests { let mut response = empty_response(); reconcile_marker( &settings, + &PublicPrefix::default(), ®istry, Some(EC_ID), &snapshot, @@ -576,6 +592,7 @@ mod tests { let mut response = empty_response(); reconcile_marker( &settings, + &PublicPrefix::default(), ®istry, Some(EC_ID), &snapshot, @@ -604,6 +621,7 @@ mod tests { let mut response = empty_response(); reconcile_marker( &settings, + &PublicPrefix::default(), ®istry, Some(EC_ID), snapshot, @@ -625,6 +643,7 @@ mod tests { reconcile_marker( &settings, + &PublicPrefix::default(), ®istry, Some(EC_ID), &EcKvSnapshot::NotRead, @@ -706,7 +725,7 @@ mod tests { #[test] fn marker_cookie_is_host_only_and_secure() { - let cookie = format_marker_cookie("value", MARKER_MAX_AGE_SECS); + let cookie = format_marker_cookie(&PublicPrefix::default(), "value", MARKER_MAX_AGE_SECS); assert_eq!( cookie, "ts-ec-pull-complete=value; Path=/; 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 1cd76907bf..a6c3ab4447 100644 --- a/crates/trusted-server-core/src/ec/resolve.rs +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -152,15 +152,17 @@ pub async fn handle_ec_resolve( // Check every response header the module asked for against core's // reserved surface, exactly as the organic generation path does in // `EcContext::generate_with_module`. A module may set its own cookies - // and headers, but not a managed `ts-` cookie, a header in the `x-ts-` + // and headers, but not a cookie under the public prefix, a header in the `x-ts-` // namespace, or a framing or hop-by-hop header. Without this a - // browser-side module could set `ts-ec` itself and walk straight past + // browser-side module could set the Edge Cookie itself and walk straight past // the identifier bounds, the conflict check and the row-before-cookie // rule below. The check sits before the identifier is read because a // module can return headers with no identifier at all, which is the // 204 path, and that path applies headers too. for (name, value) in &generated.response_headers { - if let Some(effect) = super::module::reserved_response_effect(name, value) { + if let Some(effect) = + super::module::reserved_response_effect(name, value, services.public_prefix()) + { return Err(Report::new(TrustedServerError::EdgeCookie { message: format!( "Module `{}` returned a response header `{name}` that {effect}", @@ -241,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, &mut response, &ec_id); + set_ec_cookie(settings, services.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, &mut response); + set_resolved_marker_cookie(settings, services.public_prefix(), &mut response); Ok(response) } @@ -677,7 +679,7 @@ mod tests { let err = outcome.expect_err("a managed cookie effect should fail the request"); assert!( - format!("{err:?}").contains("ts-` namespace"), + format!("{err:?}").contains("namespace the public prefix starts"), "the failure should name the reserved effect, got {err:?}" ); } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 46e47922e6..98ceb582ec 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -10,7 +10,7 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::Request; -use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; +use crate::constants::{COOKIE_EC, HEADER_X_TS_EC}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] @@ -22,6 +22,7 @@ use crate::evidence::BorrowedRequestInfo; #[cfg(test)] use crate::module_context::{ModuleContext, test_support}; use crate::platform::RuntimeServices; +use crate::prefix::PublicPrefix; use crate::settings::Settings; /// Test helper that generates a fresh EC ID with the configured Edge Cookie @@ -85,7 +86,8 @@ pub async fn generate_ec_id( /// Reads whatever the request offers as an Edge Cookie identifier, before any /// check that this deployment could have issued it. /// -/// Reads the `x-ts-ec` header first and then the `ts-ec` cookie. Both are +/// Reads the `x-ts-ec` header first and then the Edge Cookie under `public`, +/// the request's public prefix. Both are /// client-controlled. `x-ts-ec` is stripped from responses but is not stripped /// from inbound requests, so a caller must treat the result as an attacker's /// choice of string. @@ -106,6 +108,7 @@ pub async fn generate_ec_id( /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails pub(crate) fn unvalidated_ec_id_from_request( req: &Request, + public: &PublicPrefix, ) -> Result, Report> { if let Some(ec_id) = req .headers() @@ -121,7 +124,7 @@ pub(crate) fn unvalidated_ec_id_from_request( match handle_request_cookies(req)? { Some(jar) => { - if let Some(cookie) = jar.get(COOKIE_TS_EC) { + if let Some(cookie) = jar.get(&public.cookie_name(COOKIE_EC)) { let value = cookie.value(); if ec_id_has_only_allowed_chars(value) { log::trace!("Using existing EC ID from cookie"); @@ -177,7 +180,7 @@ pub fn recognized_ec_id( services: &RuntimeServices, req: &Request, ) -> Result, Report> { - let Some(ec_id) = unvalidated_ec_id_from_request(req)? else { + let Some(ec_id) = unvalidated_ec_id_from_request(req, services.public_prefix())? else { return Ok(None); }; @@ -203,7 +206,7 @@ pub fn recognized_ec_id( /// /// Attempts to retrieve an existing EC ID from: /// 1. The `x-ts-ec` header -/// 2. The `ts-ec` cookie +/// 2. The Edge Cookie, `-ec` /// /// If neither exists, generates a new EC ID via the configured module. /// @@ -219,7 +222,7 @@ pub(crate) async fn get_or_generate_ec_id_from_http_request( services: &RuntimeServices, req: &Request, ) -> Result, Report> { - if let Some(id) = unvalidated_ec_id_from_request(req)? { + if let Some(id) = unvalidated_ec_id_from_request(req, services.public_prefix())? { return Ok(Some(id)); } @@ -420,7 +423,7 @@ mod tests { // The raw reader hands it straight back, which is exactly why it is // not the check anything may rely on. assert_eq!( - unvalidated_ec_id_from_request(&req) + unvalidated_ec_id_from_request(&req, &PublicPrefix::default()) .expect("should read the header") .as_deref(), Some(forged), @@ -453,7 +456,8 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(&[(HEADER_X_TS_EC, "existing_ec_id")]); - let ec_id = unvalidated_ec_id_from_request(&req).expect("should get EC ID"); + let ec_id = unvalidated_ec_id_from_request(&req, &PublicPrefix::default()) + .expect("should get EC ID"); assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) @@ -468,10 +472,11 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(&[( header::COOKIE, - &format!("{}=existing_cookie_id", COOKIE_TS_EC), + &format!("ts-{COOKIE_EC}=existing_cookie_id"), )]); - let ec_id = unvalidated_ec_id_from_request(&req).expect("should get EC ID"); + let ec_id = unvalidated_ec_id_from_request(&req, &PublicPrefix::default()) + .expect("should get EC ID"); assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) @@ -490,8 +495,8 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); - let ec_id = - unvalidated_ec_id_from_request(&req).expect("should get EC ID from http request"); + let ec_id = unvalidated_ec_id_from_request(&req, &PublicPrefix::default()) + .expect("should get EC ID from http request"); assert_eq!(ec_id, Some("existing_http_ec_id".to_string())); } @@ -504,7 +509,7 @@ mod tests { .uri("http://example.com") .header( header::COOKIE, - format!("{}=existing_http_cookie_id", COOKIE_TS_EC), + format!("ts-{COOKIE_EC}=existing_http_cookie_id"), ) .body(edgezero_core::body::Body::empty()) .expect("should build test request"); @@ -520,7 +525,8 @@ mod tests { #[test] fn test_get_ec_id_none() { let req = create_test_request(&[]); - let ec_id = unvalidated_ec_id_from_request(&req).expect("should handle missing ID"); + let ec_id = unvalidated_ec_id_from_request(&req, &PublicPrefix::default()) + .expect("should handle missing ID"); assert!(ec_id.is_none()); } @@ -540,11 +546,11 @@ mod tests { fn test_get_ec_id_rejects_invalid_header_and_falls_back_to_cookie() { let req = create_test_request(&[ (HEADER_X_TS_EC, "evil;injected"), - (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), + (header::COOKIE, &format!("ts-{COOKIE_EC}=valid_cookie_id")), ]); - let ec_id = - unvalidated_ec_id_from_request(&req).expect("should handle invalid header gracefully"); + let ec_id = unvalidated_ec_id_from_request(&req, &PublicPrefix::default()) + .expect("should handle invalid header gracefully"); assert_eq!( ec_id, Some("valid_cookie_id".to_string()), @@ -573,13 +579,11 @@ mod tests { #[test] fn test_get_ec_id_rejects_invalid_cookie() { - let req = create_test_request(&[( - header::COOKIE, - &format!("{}=bad"#; let state = AdBidsState::with_script(bids_script); let params = OwnedProcessResponseParams { + public_prefix: crate::prefix::PublicPrefix::default(), csp_nonce_observed: None, prepared: Arc::new(Prepared::default()), template_cache_key: None, @@ -24591,6 +24625,7 @@ mod tests { // Claim gzip encoding but feed non-gzip bytes. The GzDecoder will // error as soon as it tries to read the gzip header. let params = OwnedProcessResponseParams { + public_prefix: crate::prefix::PublicPrefix::default(), csp_nonce_observed: None, prepared: Arc::new(Prepared::default()), template_cache_key: None, @@ -24712,6 +24747,7 @@ mod tests { let body = EdgeBody::from(html.to_vec()); let params = OwnedProcessResponseParams { + public_prefix: crate::prefix::PublicPrefix::default(), csp_nonce_observed: None, prepared: Arc::new(Prepared::default()), template_cache_key: None, @@ -24773,6 +24809,7 @@ mod tests { // One small script whose payload is swapped for a placeholder. let html = br#""#; let params = OwnedProcessResponseParams { + public_prefix: crate::prefix::PublicPrefix::default(), csp_nonce_observed: None, prepared: Arc::new(Prepared::default()), template_cache_key: None, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index f4d155e5ac..be7db39517 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1446,10 +1446,10 @@ impl<'de> Deserialize<'de> for PermissionSignalConfig { pub struct PrefixConfig { /// The prefix module to run, named as any module is, by its crate folder. /// - /// Core's own module runs when this is absent, giving `ts` for the names - /// the browser holds and `ts.` for shared store keys, as - /// Trusted Server always has. A name no module supplies is refused when the - /// registry is built, in + /// Core's own module runs when this is absent or names `ts`, giving `ts` + /// for the names the browser holds and `ts.` for shared + /// store keys. A name no module supplies is refused when the registry is + /// built, in /// [`IntegrationRegistry::with_plan_and_registrations`](crate::integrations::IntegrationRegistry::with_plan_and_registrations). #[serde(default, skip_serializing_if = "Option::is_none")] pub module: Option, diff --git a/crates/trusted-server-js/lib/src/shared/public_prefix.ts b/crates/trusted-server-js/lib/src/shared/public_prefix.ts index c503eb5afb..2dba302c3d 100644 --- a/crates/trusted-server-js/lib/src/shared/public_prefix.ts +++ b/crates/trusted-server-js/lib/src/shared/public_prefix.ts @@ -1,5 +1,5 @@ -// The deployment's public prefix, which every cookie name and first-party -// storage key this script writes starts with, such as `ts` for `ts-eids`. +// The deployment's public prefix, which every cookie name this script writes +// starts with, such as `ts` for `ts-eids`. // // The server writes it on the bundle's own tag, so the script holds no copy of // its own. A page whose bundle tag carries none has no names to write, and a @@ -11,9 +11,9 @@ const BUNDLE_TAG_ID = 'trustedserver-js'; /** The bundle tag attribute that carries the public prefix. */ export const PUBLIC_PREFIX_ATTRIBUTE = 'data-ts-public-prefix'; -// Letters, digits, `_`, `-` and `.`, starting with a letter or `_`, which -// every prefix the server can write is. -const PREFIX_SHAPE = /^[A-Za-z_][A-Za-z0-9._-]*$/; +// Letters, digits, `_`, `-` and `.`, starting with a letter or a single `_`, +// which every prefix the server can write is. +const PREFIX_SHAPE = /^(?!__)[A-Za-z_][A-Za-z0-9._-]*$/; /** The public prefix for this page, or `null` where the page carries none. */ export function publicPrefix(): string | null { @@ -34,11 +34,3 @@ export function cookieName(name: string): string | null { const prefix = publicPrefix(); return prefix ? `${prefix}-${name}` : null; } - -/** - * The key of the first-party storage entry `name`, written as a cookie name - * is, or `null` where the page carries no prefix. - */ -export function storageKey(name: string): string | null { - return cookieName(name); -} diff --git a/crates/trusted-server-js/lib/test/shared/public_prefix.test.ts b/crates/trusted-server-js/lib/test/shared/public_prefix.test.ts index e9e1e726df..ef9b0310a2 100644 --- a/crates/trusted-server-js/lib/test/shared/public_prefix.test.ts +++ b/crates/trusted-server-js/lib/test/shared/public_prefix.test.ts @@ -1,11 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { - PUBLIC_PREFIX_ATTRIBUTE, - cookieName, - publicPrefix, - storageKey, -} from '../../src/shared/public_prefix'; +import { PUBLIC_PREFIX_ATTRIBUTE, cookieName, publicPrefix } from '../../src/shared/public_prefix'; function serveWith(prefix: string | null): void { document.getElementById('trustedserver-js')?.remove(); @@ -25,20 +20,16 @@ describe('public prefix', () => { expect(publicPrefix()).toBe('_ex.www.example.com'); expect(cookieName('eids')).toBe('_ex.www.example.com-eids'); - expect(storageKey('evidence')).toBe('_ex.www.example.com-evidence'); }); it('names nothing on a page whose bundle tag carries none', () => { serveWith(null); expect(publicPrefix()).toBeNull(); expect(cookieName('eids')).toBeNull(); - - document.getElementById('trustedserver-js')?.remove(); - expect(storageKey('evidence')).toBeNull(); }); it('refuses a value the server could never write', () => { - for (const value of ['', '9ex', 'a b', 'a;b', 'a=b', 'a"b', 'a/b']) { + for (const value of ['', '9ex', '__ex', '__Host', 'a b', 'a;b', 'a=b', 'a"b', 'a/b']) { serveWith(value); expect(publicPrefix(), value).toBeNull(); } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e112ee441e..99f56a0ce8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -951,9 +951,9 @@ Selects the module that gives the start of every name the browser holds for a pu | Field | Type | Required | Description | | -------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `module` | String or null | No | Name of the prefix module, or omit it for core's own, which gives `ts` for the names the browser holds and `ts.` for shared store keys, the names Trusted Server always wrote | +| `module` | String or null | No | Name of the prefix module, or omit it or write `ts` for core's own, which gives `ts` for the names the browser holds and `ts.` for shared store keys | -Core's own module runs when nothing is selected, so a default deployment writes `ts-ec`, `ts-eids` and `ts-tester` as it always has. A module from a crate at `crates/prefix/` is written `` or `prefix.`, and the crate's own module has to be selected in the section of its type as well. Naming a module this deployment does not run fails at startup, with the prefix modules it does run, and a module that names a value its call never carries is refused at startup rather than on a request. +Core's own module runs when nothing is selected, so a default deployment writes `ts-ec`, `ts-eids` and `ts-tester`. A module from a crate at `crates/prefix/` is written `` or `prefix.`, and the crate's own module has to be selected in the section of its type as well. Naming a module this deployment does not run fails at startup, with the prefix modules it does run, and a module that names a value its call never carries is refused at startup rather than on a request. A public prefix carries no domain, because a browser keeps each site's names apart from every other site's. A server key prefix carries the publisher's domain with a single leading `www.` folded, so `www.example.com` and `example.com` share keys and `clients.example.com` and `public.example.com` do not, and the domain is the loaded settings' `publisher.domain`, never the request's `Host` header. The page's bundle tag tells the client script the public prefix, as `data-ts-public-prefix`, so the script names its own cookies after it and holds no copy. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 5e6cc9bd31..971165f83d 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -858,13 +858,13 @@ Code in core, or in a module, loads through the cache with the cache's name, the lifetimes of the memory and store copies, how many values the memory copy holds and whether a load may run apart from the request that started it, which only a load that never blocks its thread may. Its key -is a `StoreKey`, which starts with the publisher's `ServerKeyPrefix` from the -loaded settings, so two publishers sharing a store never share a copy. The -`Loader` it hands over owns everything it uses, and answers a `LoadedValue`, -with a lifetime of its own where the source gives one. +is a `StoreKey`, which starts with the publisher's `ServerKeyPrefix` from +`services.server_key_prefix()`, so two publishers sharing a store never share +a copy. The `Loader` it hands over owns everything it uses, and answers a +`LoadedValue`, with a lifetime of its own where the source gives one. ```rust -let key = StoreKey::new(&ServerKeyPrefix::for_settings(settings), CACHE, digest); +let key = StoreKey::new(services.server_key_prefix(), CACHE, digest); let bytes = services .load_cached( &cache_spec(settings), From e553db9bc01995d0dde8eb22d1e86534631064c0 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 20:36:11 +0100 Subject: [PATCH 07/15] The data page sandbox test passes the public prefix The Fastly test of the data page's sandbox called handle_data with the three arguments it took before the prefix module, so the adapter's tests did not compile. It now passes the default public prefix. The [prefix] table in the configuration guide is formatted as Prettier writes it. --- crates/trusted-server-adapter-fastly/src/middleware.rs | 9 +++++++-- docs/guide/configuration.md | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 60942275b0..a7c02610a3 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -409,8 +409,13 @@ mod tests { .uri("https://publisher.example/_ts/data") .body(Body::empty()) .expect("should build request"); - let mut response = trusted_server_core::inspect::data::handle_data(None, None, &request) - .expect("should answer"); + let mut response = trusted_server_core::inspect::data::handle_data( + None, + None, + &trusted_server_core::prefix::PublicPrefix::default(), + &request, + ) + .expect("should answer"); apply_finalize_headers(&settings, None, &mut response); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 99f56a0ce8..5ddc4a1f60 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -949,8 +949,8 @@ Selects the module that gives the start of every name the browser holds for a pu ### `[prefix]` -| Field | Type | Required | Description | -| -------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Field | Type | Required | Description | +| -------- | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `module` | String or null | No | Name of the prefix module, or omit it or write `ts` for core's own, which gives `ts` for the names the browser holds and `ts.` for shared store keys | Core's own module runs when nothing is selected, so a default deployment writes `ts-ec`, `ts-eids` and `ts-tester`. A module from a crate at `crates/prefix/` is written `` or `prefix.`, and the crate's own module has to be selected in the section of its type as well. Naming a module this deployment does not run fails at startup, with the prefix modules it does run, and a module that names a value its call never carries is refused at startup rather than on a request. From a433d6d03bee5c47206a468f6955c6b0bc7c5b52 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 01:19:52 +0100 Subject: [PATCH 08/15] The guide says what selecting another prefix orphans --- docs/guide/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index da2709ac1a..4d7a159a96 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -955,6 +955,8 @@ Selects the module that gives the start of every name the browser holds for a pu Core's own module runs when nothing is selected, so a default deployment writes `ts-ec`, `ts-eids` and `ts-tester`. A module from a crate at `crates/prefix/` is written `` or `prefix.`, and the crate's own module has to be selected in the section of its type as well. Naming a module this deployment does not run fails at startup, with the prefix modules it does run, and a module that names a value its call never carries is refused at startup rather than on a request. +A prefix is chosen when a deployment starts. Selecting a module other than the one a deployment ran before orphans every name the browser holds under the old prefix, being each reader's Edge Cookie, resolved marker, EIDs and tester cookie, and every entry in a shared store under the old key prefix, being the identity-graph rows behind them, because nothing reads an old prefix. A change is a cutover the publisher plans, with every reader starting again, not a setting to move between builds. + A public prefix carries no domain, because a browser keeps each site's names apart from every other site's. A server key prefix carries the publisher's domain with a single leading `www.` folded, so `www.example.com` and `example.com` share keys and `clients.example.com` and `public.example.com` do not, and the domain is the loaded settings' `publisher.domain`, never the request's `Host` header. The page's bundle tag tells the client script the public prefix, as `data-ts-public-prefix`, so the script names its own cookies after it and holds no copy. **Example**: From c61b6c508302a791c08224be67f1484dc131b7fc Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 22:48:49 +0100 Subject: [PATCH 09/15] The Prebid test closes the test the merge of main left open --- .../lib/test/integrations/prebid/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 870c99d02c..6a4462f4a0 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -3260,6 +3260,8 @@ describe('prebid/installPrebidNpm', () => { } as unknown as RequestBidsArg); expect(document.cookie).toBe(''); + }); + it('trims an oversized ts-eids cookie deterministically and logs which sources were dropped', () => { const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); From 9a99934236ed66f50eba62dd8de91cf42a14da25 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 23:17:45 +0100 Subject: [PATCH 10/15] The served shim's size bound allows the cookie names the public prefix gives it --- .../lib/test/prebid-artifact-integration.test.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 24db647d31..3abb577e07 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -307,10 +307,11 @@ describe('tsjs-prebid production artifacts', () => { expect(analyticsArtifact.bundleCode.length).toBeGreaterThan(200_000); // A value-import of Prebid or a private rendering helper would multiply // the shim size. The bound sits just above the normal compact shim output, - // which is roughly 40 KB: tight enough that material growth has to be - // noticed and re-justified here, and far enough below a multiplication - // that one still fails loudly. The bundle beside it is 200 KB and up. - expect(shimCode.length).toBeLessThan(41_000); + // which is roughly 41 KB once the shim names its cookies after the page's + // public prefix: tight enough that material growth has to be noticed and + // re-justified here, and far enough below a multiplication that one still + // fails loudly. The bundle beside it is 200 KB and up. + expect(shimCode.length).toBeLessThan(42_000); expect(shimCode).toContain('markWinningBidAsUsed'); }); From c1438a53784dbc6532e7661e09d48b92e4f950c5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 10:33:56 +0100 Subject: [PATCH 11/15] Read the kept request's Host without a panic EntryRequest::of, which keeps the request's method, host, scheme, path and query for the module calls that run after the request is handed to the router, read Host with get_header_str, which panics on a value whose bytes are not valid UTF-8. A client chooses those bytes, and a request addressed by a hook can reach this read with such a Host. It now reads Host through request_host, as entry_request and the selection by host do, so such a Host is kept as no host. Test: a_kept_entry_request_reads_a_host_that_is_not_utf8_as_no_host builds the kept request from a Host of the bytes b"\xffa.example" and checks the kept host and the module request it makes are both empty. --- .../trusted-server-adapter-fastly/src/lib.rs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 07821d2f54..5f8c9e355d 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -1177,10 +1177,7 @@ impl EntryRequest { fn of(req: &FastlyRequest) -> Self { Self { method: req.get_method().clone(), - host: req - .get_header_str(header::HOST.as_str()) - .unwrap_or_default() - .to_owned(), + host: request_host(req).unwrap_or_default().to_owned(), scheme: entry_scheme(req), path: req.get_path().to_owned(), query: req.get_query_str().unwrap_or_default().to_owned(), @@ -1522,6 +1519,21 @@ mod tests { ); } + #[test] + fn a_kept_entry_request_reads_a_host_that_is_not_utf8_as_no_host() { + let kept = EntryRequest::of(&request_with_host_not_utf8()); + + assert_eq!( + kept.host, "", + "a kept request should carry no host where Host is not UTF-8" + ); + assert_eq!( + kept.module_request().host(), + "", + "the module request a kept request makes should hand on no host" + ); + } + #[test] fn a_malformed_selector_is_answered_500() { let response = refusal_response(&config_selection::Refusal::MalformedSelector( From c364ff3e80304b7092258281743ff48653d92ca8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 11:59:28 +0100 Subject: [PATCH 12/15] Name the resolved marker as this branch writes it in the carried tests This branch's prefix module gives the start of every name the browser holds, so COOKIE_TS_EC_RESOLVED became COOKIE_EC_RESOLVED, the name's end. The three tests that the demo Edge Cookie module fix brought up from split/4-client-resolve named the marker by the old constant, so core's tests stopped compiling here. They now name the marker `ts-ecr`, its name under the default public prefix, as this branch's own resolve tests do. --- crates/trusted-server-core/src/ec/resolve.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs index cb74f01472..a694072c00 100644 --- a/crates/trusted-server-core/src/ec/resolve.rs +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -424,7 +424,6 @@ fn content_length_exceeds_limit(req: &Request, limit: usize) -> bool { mod tests { use super::*; use crate::consent::types::ConsentContext; - use crate::constants::COOKIE_TS_EC_RESOLVED; use crate::ec::finalize::ec_finalize_response; use crate::ec::module::{ CLIENT_FIXED_MODULE_KEY, EcModuleSelection, EdgeCookieModule, GeneratedEdgeCookie, @@ -1356,6 +1355,9 @@ mod tests { .build() } + /// The resolved marker's name under the default public prefix. + const RESOLVED_MARKER: &str = "ts-ecr"; + /// The publisher cookies a browser holds, by name. type Jar = BTreeMap; @@ -1476,7 +1478,7 @@ mod tests { "the first post should create the identity" ); assert_eq!( - jar.get(COOKIE_TS_EC_RESOLVED).map(String::as_str), + jar.get(RESOLVED_MARKER).map(String::as_str), Some("1"), "the resolve should set the marker" ); @@ -1489,7 +1491,7 @@ mod tests { "core should recognize the demo module's own identifier when it comes back" ); assert_eq!( - jar.get(COOKIE_TS_EC_RESOLVED).map(String::as_str), + jar.get(RESOLVED_MARKER).map(String::as_str), Some("1"), "the next view should keep the marker once it is set, got {jar:?}" ); @@ -1507,7 +1509,7 @@ mod tests { view_page(&settings, &services, &graph, &mut jar).await; // The page script posts only when it finds no marker, which its // own tests pin in `ec_client_fixed/index.test.ts`. - if !jar.contains_key(COOKIE_TS_EC_RESOLVED) { + if !jar.contains_key(RESOLVED_MARKER) { posts += 1; post_fixed_word(&settings, &services, &graph, &mut jar).await; } From 79c1199f726a84721016b2f50e666c752947cbb5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 04:48:26 +0100 Subject: [PATCH 13/15] Core's own prefix module is the default selection, built in one place The default selection was built by SelectedPrefixModule::trusted_server, which the registry called when nothing or ts is selected and which Default called too. The code scanning rule for cleartext logging takes a function whose name holds "trusted" to return a secret, so it followed that module into the registry and the auction and reported a count of planned providers and a skipped module's name as logged secrets. The registry now asks for SelectedPrefixModule::default, as its other uses already do, Default builds core's own module itself, and the constructor, which nothing else calls, is gone. --- crates/trusted-server-core/src/integrations/registry.rs | 2 +- crates/trusted-server-core/src/prefix.rs | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 5afe483a0f..c8dbe18571 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1285,7 +1285,7 @@ fn resolve_prefix_module( inner: &IntegrationRegistryInner, ) -> Result> { let selected = match settings.prefix.module.as_deref() { - None | Some(crate::prefix::TRUSTED_SERVER_MODULE) => SelectedPrefixModule::trusted_server(), + None | Some(crate::prefix::TRUSTED_SERVER_MODULE) => SelectedPrefixModule::default(), Some(written) => { let module = named_module(PREFIX_TYPE, "prefix", written, &inner.prefix_modules, inner)?; diff --git a/crates/trusted-server-core/src/prefix.rs b/crates/trusted-server-core/src/prefix.rs index 357b442c85..de7b73fa41 100644 --- a/crates/trusted-server-core/src/prefix.rs +++ b/crates/trusted-server-core/src/prefix.rs @@ -280,12 +280,6 @@ impl SelectedPrefixModule { Self { name, module } } - /// Core's own module, which runs when nothing is selected. - #[must_use] - pub fn trusted_server() -> Self { - Self::new(TRUSTED_SERVER_MODULE, Arc::new(TrustedServerPrefix)) - } - /// The name the module runs under. #[must_use] pub fn name(&self) -> &'static str { @@ -307,9 +301,10 @@ impl SelectedPrefixModule { } } +/// Core's own module, which runs when nothing is selected. impl Default for SelectedPrefixModule { fn default() -> Self { - Self::trusted_server() + Self::new(TRUSTED_SERVER_MODULE, Arc::new(TrustedServerPrefix)) } } From a778dd7ca4afb5de0c442e655f240bfbf5973497 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:06 +0000 Subject: [PATCH 14/15] The prefix tests' settings carry the sample policy --- crates/trusted-server-adapter-axum/src/app.rs | 4 +++- crates/trusted-server-adapter-cloudflare/src/app.rs | 4 +++- crates/trusted-server-adapter-spin/src/app.rs | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 62dcbc9faf..115b09bb37 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -917,7 +917,9 @@ mod tests { assume_single_jurisdiction = true "#, ) - .expect("should parse the settings"); + .expect("should parse the settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); let state = build_state_with_settings(settings).expect("should build the state"); let request = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 3567dffff7..7ffdb10f32 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -1082,7 +1082,9 @@ mod tests { assume_single_jurisdiction = true "#, ) - .expect("should parse the settings"); + .expect("should parse the settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); let state = build_state_with_settings(settings).expect("should build the state"); let request = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index a919c11864..7bf19e8554 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1297,7 +1297,9 @@ mod tests { assume_single_jurisdiction = true "#, ) - .expect("should parse the settings"); + .expect("should parse the settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); let state = build_state_with_settings(settings).expect("should build the state"); let request = request_builder() .method("GET") From 22f3bc580decf26ebd9eecad1ce116dda0b9fb4e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:07 +0000 Subject: [PATCH 15/15] The policy digest test passes the default public prefix --- crates/trusted-server-core/src/publisher.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index b301cac0f0..204ded6ae0 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -10979,15 +10979,20 @@ mod tests { }; let registry = test_registry(&create_test_settings()); + let public = crate::prefix::PublicPrefix::default(); assert_eq!( - template_fingerprint(&with_policy("digest-a", "groups: {}\n"), ®istry), - template_fingerprint(&with_policy("digest-a", "a different text\n"), ®istry), + template_fingerprint(&with_policy("digest-a", "groups: {}\n"), ®istry, &public), + template_fingerprint( + &with_policy("digest-a", "a different text\n"), + ®istry, + &public + ), "the text should not reach the fingerprint" ); assert_ne!( - template_fingerprint(&with_policy("digest-a", "groups: {}\n"), ®istry), - template_fingerprint(&with_policy("digest-b", "groups: {}\n"), ®istry), + template_fingerprint(&with_policy("digest-a", "groups: {}\n"), ®istry, &public), + template_fingerprint(&with_policy("digest-b", "groups: {}\n"), ®istry, &public), "a different policy file should select a different template" ); }