From 15af0ec4cd30f4c017aa770c427cb9b41c977e05 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 19 Aug 2026 15:06:23 +0100 Subject: [PATCH 01/24] Add the client-set Edge Cookie value path Fourth slice of the PR 838 decomposition. A client-cycle Edge Cookie provider defers at the edge and lets the page derive the identifier in the browser; the page posts it to POST /_ts/api/v1/ec/resolve and the provider verifies it before the edge mints it as the Edge Cookie: - EdgeCookieProvider gains resolve_from_client with a no-op default, so server-side providers are untouched. ClientResolveInput carries the posted payload plus the request's resolved permissions and consent. - The resolve endpoint enforces the same rules as organic generation and several of its own. The permission gate applies unchanged. The request must carry an Origin on the publisher's domain (an identity-setting POST must not be drivable from a foreign page) and a text/plain or application/json body. A minted identifier must fit the identifier bounds (400), must not silently replace a different identity already on the request (409), and is persisted to the identity graph before the cookie is set, keyed by the provider's canonical form, so withdrawal reaches a client-set identity the same way it reaches an edge-minted one. With no graph available nothing is minted, matching the organic rule against phantom cookies; a graph write failure is 503. Every response carries Cache-Control: no-store. - The Edge Cookie stays HttpOnly. A non-HttpOnly companion marker cookie (ts-ecr=1, no identity content) tells the page script a resolve succeeded, so it does not re-post on every page view; the marker expires together with the Edge Cookie on withdrawal. A Rust test asserts the marker name and the demo's fixed word stay in sync with the page script source. - The client-fixed demonstration provider (fixed shared word, verify-before-mint) is compiled only behind the trusted-server-core client-fixed-demo cargo feature. Production builds reject the selection at startup: a fixed shared word is not an identity. - The Fastly adapter routes the endpoint and passes the same bot-gated identity graph as generation, so unrecognized clients cannot mint through resolve either. The other adapters deliberately do not route it yet, matching identify and batch-sync, which need the same platform KV wiring those adapters lack. The design spec for this slice lives at docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md, the 2026-07-31 threat-model draft revised to the implemented state, with the deferred reservation design retained verbatim as the bar for the first vendor scheme. --- .../trusted-server-adapter-fastly/src/app.rs | 36 +- crates/trusted-server-adapter-spin/src/app.rs | 3 +- crates/trusted-server-core/Cargo.toml | 3 + crates/trusted-server-core/src/constants.rs | 5 + crates/trusted-server-core/src/ec/cookies.rs | 144 +++- crates/trusted-server-core/src/ec/mod.rs | 43 +- crates/trusted-server-core/src/ec/provider.rs | 413 ++++++++++- crates/trusted-server-core/src/ec/resolve.rs | 694 ++++++++++++++++++ .../src/integrations/registry.rs | 58 ++ crates/trusted-server-core/src/settings.rs | 40 +- .../src/integrations/ec_client_fixed/index.ts | 65 ++ .../ec_client_fixed/index.test.ts | 65 ++ docs/guide/api-reference.md | 12 + 13 files changed, 1555 insertions(+), 26 deletions(-) create mode 100644 crates/trusted-server-core/src/ec/resolve.rs create mode 100644 crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/ec_client_fixed/index.test.ts diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 2445ca467d..0b2eb634d6 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -30,6 +30,7 @@ //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | //! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | +//! | POST | `/_ts/api/v1/ec/resolve` | [`handle_ec_resolve`] | //! | POST | `/auction` | [`handle_auction`] | //! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | //! | GET | `/first-party/click` | [`handle_first_party_click`] | @@ -115,6 +116,7 @@ use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::provider::request_provider; use trusted_server_core::ec::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::ec::resolve::handle_ec_resolve; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; use trusted_server_core::integrations::{ @@ -125,8 +127,6 @@ use trusted_server_core::permissions::PermissionState; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, build_geo_provider, }; -use trusted_server_device_fastly::FastlyHostSignals; - use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -145,6 +145,7 @@ use trusted_server_core::settings_data::{ config_key, config_store_name, get_settings_from_config_store, }; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_device_fastly::FastlyHostSignals; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ @@ -723,6 +724,12 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::EcResolve => { + // The resolve endpoint persists the identity-graph row before + // minting, so it takes the same bot-gated graph as generation: an + // unrecognized client gets no graph, and therefore no mint. + handle_ec_resolve(&state.settings, req, &ec.ec_context, ec.kv_graph.as_ref()) + } NamedRouteHandler::Auction => { // The auction reads consent data, so the consent KV store must be // available — fail closed with 503 when it is configured but @@ -1153,6 +1160,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + EcResolve, Auction, PageBids, FirstPartyProxy, @@ -1253,6 +1261,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + NamedRoute { + path: "/_ts/api/v1/ec/resolve", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::EcResolve, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -2260,6 +2273,25 @@ mod tests { ); } + #[test] + fn dispatch_ec_resolve_routes_to_resolve_handler() { + // Parity guard: POST /_ts/api/v1/ec/resolve must reach the resolve + // handler, not the publisher fallback or a router-level 405. The test + // request carries no Origin, so the handler's own origin guard answers + // 403, proving the request was handled here rather than proxied to + // the publisher origin (which would error without a live backend). + let router = test_router(); + let response = + block_on(router.oneshot(empty_request(Method::POST, "/_ts/api/v1/ec/resolve"))) + .expect("should route request"); + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "POST ec/resolve should reach the resolve handler's origin guard, not the publisher" + ); + } + #[test] fn dispatch_fallback_attaches_ec_finalize_state() { // The publisher fallback must thread EC finalize state to the entry diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 5ce3fb3003..4b91973204 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -169,7 +169,8 @@ fn publisher_fallback_methods() -> [Method; 7] { // reaches the publisher origin rather than a router-level 405. // // The EC API routes that the Fastly entry point registers — POST -// `/_ts/api/v1/batch-sync`, GET/OPTIONS `/_ts/api/v1/identify` — are +// `/_ts/api/v1/batch-sync`, GET/OPTIONS `/_ts/api/v1/identify`, POST +// `/_ts/api/v1/ec/resolve` — are // intentionally absent here, matching the Axum and Cloudflare adapters: those // handlers require a platform KV `ec_store` (and, for batch-sync, a partner // registry and rate limiter) that the portability adapters do not yet wire. diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index afff4c696e..8413f31aec 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -60,6 +60,9 @@ getrandom = { workspace = true, features = ["js"] } uuid = { workspace = true, features = ["js"] } [features] +# Compiles in the client-fixed demonstration Edge Cookie provider. Off by +# default so production builds cannot select a fixed shared word as identity. +client-fixed-demo = [] default = [] # Exposes test-only constructors (e.g. `IntegrationRegistry::from_request_filters`) # so downstream crates can build registries with stub integrations in their tests. diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index 2e7df513b6..15531ec52f 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -1,6 +1,11 @@ use http::header::HeaderName; pub const COOKIE_TS_EC: &str = "ts-ec"; +/// Non-`HttpOnly` companion to [`COOKIE_TS_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"; /// 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"; diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index 08b1383890..f87c69d5d6 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -16,7 +16,7 @@ use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; -use crate::constants::COOKIE_TS_EC; +use crate::constants::{COOKIE_TS_EC, COOKIE_TS_EC_RESOLVED}; use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). @@ -106,6 +106,48 @@ pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_ } } +/// Sets an Edge Cookie minted by a client-cycle provider on the response. +/// +/// Unlike [`set_ec_cookie`], the value is treated as opaque: it need not match +/// the canonical HMAC id shape, because a client-cycle provider's identifier +/// (for example a signed envelope, or the client-random demo's value) is not in +/// that format. The value is still validated against the same identifier +/// alphabet and length bound as every other identifier +/// ([`ec_id_has_only_allowed_chars`]); a value outside the bounds is rejected +/// (no cookie set) and logged, never rewritten, which prevents header +/// injection. The same `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, and +/// `Domain` attributes as [`set_ec_cookie`] apply. +pub(crate) fn set_provider_ec_cookie( + settings: &Settings, + response: &mut Response, + value: &str, +) { + if !ec_id_has_only_allowed_chars(value) { + log::error!( + "Rejecting provider Edge Cookie value of {} bytes: empty, over {} bytes, or outside the identifier alphabet", + value.len(), + MAX_EC_ID_LEN, + ); + return; + } + + let cookie = format_set_cookie( + &settings.publisher.ec_cookie_domain(), + value, + COOKIE_MAX_AGE, + ); + match HeaderValue::from_str(&cookie) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + // Unreachable in practice: the identifier allowlist gates the value + // and format_set_cookie emits only controlled bytes. + log::warn!("Skipping provider EC Set-Cookie: invalid header value: {e}"); + } + } +} + /// Expires the EC cookie by setting `Max-Age=0`. /// /// Used when a user revokes consent — the browser will delete the cookie @@ -125,6 +167,46 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) log::warn!("Skipping EC cookie expiry Set-Cookie: invalid header value: {e}"); } } + // Expire the resolved marker together with the Edge Cookie, so a page + // whose visitor later re-establishes the permission can resolve again. + let marker = format!( + "{COOKIE_TS_EC_RESOLVED}=; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age=0", + settings.publisher.ec_cookie_domain(), + ); + match HeaderValue::from_str(&marker) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + log::warn!("Skipping resolved-marker expiry Set-Cookie: invalid header value: {e}"); + } + } +} + +/// Sets the `non-HttpOnly` resolved-marker cookie alongside a client-cycle Edge +/// Cookie. +/// +/// The Edge Cookie itself is `HttpOnly`, so the page script cannot see it and +/// would otherwise post to the resolve endpoint on every page view. The marker +/// carries no identity (its value is `1`); it only signals that a resolve +/// succeeded. It shares the Edge Cookie's `Domain`, `Path`, `Secure`, +/// `SameSite`, and lifetime, and is expired together with it by +/// [`expire_ec_cookie`]. +pub(crate) fn set_resolved_marker_cookie(settings: &Settings, response: &mut Response) { + let cookie = format!( + "{COOKIE_TS_EC_RESOLVED}=1; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}", + settings.publisher.ec_cookie_domain(), + ); + match HeaderValue::from_str(&cookie) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + // Unreachable in practice: the value is a constant and the domain + // comes from operator-trusted configuration. + log::warn!("Skipping resolved-marker Set-Cookie: invalid header value: {e}"); + } + } } #[cfg(test)] @@ -328,4 +410,64 @@ foo=bar", "expiry cookie should retain the same security attributes as the live cookie" ); } + + #[test] + fn set_provider_ec_cookie_sets_opaque_value_with_security_attributes() { + let settings = create_test_settings(); + let mut response = empty_response(); + // A client-cycle value that is not the canonical HMAC id shape. + set_provider_ec_cookie(&settings, &mut response, "8473625190"); + + let cookie_str = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be valid UTF-8"); + + assert_eq!( + cookie_str, + format!( + "{}=8473625190; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly", + COOKIE_TS_EC, settings.publisher.domain, COOKIE_MAX_AGE, + ), + "an opaque provider value should be set verbatim with the standard security attributes" + ); + } + + #[test] + fn set_provider_ec_cookie_preserves_base64url_value() { + let settings = create_test_settings(); + let mut response = empty_response(); + // A base64url value (RFC 4648 section 5, unpadded) fits the identifier + // alphabet exactly and must be preserved verbatim. Standard base64 + // (`+`, `/`, `=`) is outside the alphabet, so a provider that carries + // binary data re-encodes it as base64url before minting. + let value = "abcDEF123-_x"; + set_provider_ec_cookie(&settings, &mut response, value); + + let cookie_str = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be valid UTF-8"); + + assert!( + cookie_str.starts_with(&format!("{COOKIE_TS_EC}={value};")), + "a base64url provider value should be preserved verbatim, got {cookie_str}" + ); + } + + #[test] + fn set_provider_ec_cookie_rejects_unsafe_value() { + let settings = create_test_settings(); + let mut response = empty_response(); + set_provider_ec_cookie(&settings, &mut response, "evil; Domain=.attacker.com"); + + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an unsafe value must not set a cookie, preventing header injection" + ); + } } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 7bc4cdc309..6ad876f721 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -49,6 +49,7 @@ pub mod provider; pub mod pull_sync; pub mod rate_limiter; pub mod registry; +pub mod resolve; /// Truncates an EC ID for safe inclusion in log messages. /// @@ -74,7 +75,7 @@ use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; -use crate::evidence::BorrowedRequestInfo; +use crate::evidence::{BorrowedRequestInfo, HostSignals}; use crate::geo::GeoInfo; use crate::permissions::{Acquisition, Permission, PermissionState}; use crate::platform::RuntimeServices; @@ -163,6 +164,16 @@ pub struct EcContext { /// Set via [`EcContext::set_device_signals`] before /// [`EcContext::generate_if_needed`] is called. device_signals: Option, + /// The host-signal service for this request, when the host supplies one + /// (the Fastly adapter registers the TLS/HTTP-2 fingerprints). `None` on a + /// host that exposes none. Injected into a provider that needs it when the + /// provider is built. + host_signals: Option>, + /// The adapter-injected Edge Cookie provider, when one is wired for this + /// request. Captured once from [`RuntimeServices`] at construction and + /// passed to [`build_provider`] on every path, so a vendor or host provider + /// resolves without core naming it. `None` for built-in-only deployments. + ec_provider: Option>, /// The selected Edge Cookie provider (built-in or injected), built once at /// construction. Core asks it whether an identifier is well formed /// ([`accepts_id`](crate::ec::provider::EdgeCookieProvider::accepts_id)) so @@ -289,6 +300,12 @@ impl EcContext { let selected_provider: Option> = provider::request_provider(&settings.ec, services)?; + // The same two services are kept on the context so the resolve endpoint, + // which runs later in the request with no `RuntimeServices` of its own, + // can reach the provider again. + let host_signals = services.host_signals(); + let ec_provider = services.ec_provider(); + // Read back an existing identifier only when the selected provider // accepts its shape, so an opaque vendor identifier (for example a signed // envelope) round-trips instead of being silently dropped by the built-in @@ -379,6 +396,8 @@ impl EcContext { client_ip, geo_info: geo_info.cloned(), device_signals: None, + host_signals, + ec_provider, selected_provider, request_headers, request_path, @@ -734,6 +753,22 @@ impl EcContext { self.client_ip.as_deref() } + /// Returns the host-computed client fingerprints captured for this request, + /// when the host supplies them. + /// + /// The resolve path rebuilds the provider with the same injected services + /// as the organic path, so it reads the host signals captured here rather + /// than re-deriving them. + pub(crate) fn host_signals(&self) -> Option> { + self.host_signals.clone() + } + + /// Returns the adapter-injected Edge Cookie provider captured for this + /// request, or `None` for a built-in-only deployment. + pub(crate) fn ec_provider(&self) -> Option> { + self.ec_provider.clone() + } + /// Returns the pre-routing geo data, if available. #[must_use] pub fn geo_info(&self) -> Option<&GeoInfo> { @@ -864,6 +899,8 @@ impl EcContext { client_ip: None, geo_info: None, device_signals: None, + host_signals: None, + ec_provider: None, selected_provider: None, request_headers: http::HeaderMap::new(), request_path: String::new(), @@ -892,6 +929,8 @@ impl EcContext { client_ip, geo_info: None, device_signals: None, + host_signals: None, + ec_provider: None, selected_provider: None, request_headers: http::HeaderMap::new(), request_path: String::new(), @@ -924,6 +963,8 @@ impl EcContext { client_ip: None, geo_info: None, device_signals: None, + host_signals: None, + ec_provider: None, selected_provider: None, request_headers: http::HeaderMap::new(), request_path: String::new(), diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 7aaf6bc34d..5b31e1458c 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -131,6 +131,20 @@ pub const HMAC_PROVIDER_KEY: &str = "hmac"; /// arm when the host-signal provider becomes a module of its own. pub const HOST_SIGNALS_PROVIDER_KEY: &str = "host-signals"; +/// The configuration name of the client-fixed demonstration provider. +/// +/// An ordinary name in the same open-ended namespace as [`HMAC_PROVIDER_KEY`], +/// and nothing branches on it outside the resolution in [`build_provider`]. It +/// is also `ClientFixedProvider`'s `id`, and it goes with that resolution arm +/// when the demonstration provider becomes a module of its own. The provider +/// type is not linked here because it is compiled in only under the +/// `client-fixed-demo` cargo feature, while this name is always spelled. +/// +/// The name is spelled here whether or not the provider is compiled in, +/// because a build without it still has to recognize the name to reject the +/// selection at startup rather than at the first request. +pub const CLIENT_FIXED_PROVIDER_KEY: &str = "client-fixed"; + /// The provider names core supplies itself. /// /// A name in this list is already taken, so an adapter that injects a provider @@ -140,7 +154,15 @@ pub const HOST_SIGNALS_PROVIDER_KEY: &str = "host-signals"; /// shrinks with them, and it empties when the providers still built into core /// become modules like every other provider, at which point no name is /// reserved and every provider is injected. -const BUILTIN_PROVIDER_KEYS: &[&str] = &[HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY]; +/// +/// [`CLIENT_FIXED_PROVIDER_KEY`] is listed whether or not the demonstration +/// provider is compiled in, for the same reason the name itself is always +/// spelled, which is that a build without it still owns the name. +const BUILTIN_PROVIDER_KEYS: &[&str] = &[ + HMAC_PROVIDER_KEY, + HOST_SIGNALS_PROVIDER_KEY, + CLIENT_FIXED_PROVIDER_KEY, +]; /// The registry code of the built-in HMAC provider. /// @@ -173,6 +195,30 @@ pub struct IdentityInput<'a> { pub consent: Option<&'a ConsentContext>, } +/// Inputs available to [`EdgeCookieProvider::resolve_from_client`]. +/// +/// Carries the value a client produced and posted to the Edge Cookie resolve +/// endpoint, alongside the same gating context as [`IdentityInput`]. Request data +/// reaches the provider through its injected services. Unlike trusted edge-derived +/// data, [`payload`](Self::payload) arrives from the browser, so an +/// implementation must verify it before deriving an identifier from it. +pub struct ClientResolveInput<'a> { + /// The raw body the client posted to the resolve endpoint. For a vendor + /// provider this is its own JSON envelope; for the built-in + /// [`ClientFixedProvider`] demo it is the fixed known word the page script + /// posts. + pub payload: &'a [u8], + + /// The permissions resolved for the resolve request. The endpoint has + /// already confirmed the provider's required permissions are set, so a + /// provider reads this only for behavior beyond gating. + pub permissions: Option<&'a PermissionState>, + + /// The resolve request's consent context, for provider-specific logic. The + /// core gates on permissions, not consent. + pub consent: Option<&'a ConsentContext>, +} + /// The outcome of [`EdgeCookieProvider::generate`]. /// /// Carries the derived identifier, if any, and any response headers the provider @@ -614,6 +660,11 @@ impl<'a> AcceptedProviders<'a> { /// - **Server-side** (for example [`HmacProvider`]): derives the identifier at /// the edge in [`generate`](Self::generate), and the page response sets the /// cookie. Nothing client-side is involved. +/// - **Client-side** (for example [`ClientFixedProvider`]): defers in +/// [`generate`](Self::generate) (returns `id: None`), runs its own JavaScript +/// in the browser, and mints from the value the page posts back in +/// [`resolve_from_client`](Self::resolve_from_client), whose response sets the +/// cookie. /// /// A provider that cannot derive an identifier at the edge returns a /// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so @@ -653,6 +704,10 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// Derives an Edge Cookie identifier from the request evidence in /// `request_info` and the gating context in `input`. /// + /// A server-side provider mints here. A client-side provider defers here + /// (returns `id: None`) and mints later in + /// [`resolve_from_client`](Self::resolve_from_client) from the value the page + /// posts back. /// /// # Errors /// @@ -703,6 +758,30 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { fn required_permissions(&self) -> PermissionSet { PermissionSet::none() } + + /// Derives an Edge Cookie identifier from a value the client produced and + /// posted to the resolve endpoint (`POST /_ts/api/v1/ec/resolve`). + /// + /// This is the client-side counterpart to [`generate`](Self::generate). A + /// provider that cannot derive an identifier at the edge defers from + /// `generate` (returning `id: None`, optionally with response headers that + /// trigger client-side work), and the page posts its result back here. The + /// payload arrives from the browser, so an implementation MUST verify it + /// (for example checking a signature) before trusting it. The default + /// returns no identifier, so a provider that mints entirely server-side + /// (such as [`HmacProvider`]) need not implement it. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when processing the payload + /// fails. A payload that is merely unverified or absent yields `id: None` + /// rather than an error, so the request proceeds without an Edge Cookie. + fn resolve_from_client( + &self, + _input: &ClientResolveInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } } /// The built-in HMAC Edge Cookie provider. @@ -838,6 +917,91 @@ impl EdgeCookieProvider for HostSignalProvider { } } +/// The fixed, known word shared by [`ClientFixedProvider`] and its page script. +/// +/// Kept cookie-safe (no characters [`set_provider_ec_cookie`] would reject) so +/// it can be used as the Edge Cookie value verbatim. The page script posts this +/// exact string; the provider mints only when the posted value matches. The +/// client copy lives in +/// `crates/trusted-server-js/lib/src/integrations/ec_client_fixed`. +/// +/// [`set_provider_ec_cookie`]: super::cookies::set_provider_ec_cookie +#[cfg(any(test, feature = "client-fixed-demo"))] +const EXPECTED_VALUE: &str = "an-ec"; + +/// A demonstration client-side provider, with no vendor coupling. +/// +/// Client and server share one fixed, known word (`EXPECTED_VALUE`). When no +/// Edge Cookie is present the page script (delivered through the tsjs bundle) +/// posts that word to `POST /_ts/api/v1/ec/resolve`, and this provider mints the +/// Edge Cookie only when the posted value matches. It defers from +/// [`generate`](EdgeCookieProvider::generate) so the page renders with no Edge +/// Cookie until the client reports back, then verifies and mints in +/// [`resolve_from_client`](EdgeCookieProvider::resolve_from_client). +/// +/// The value is verifiable precisely because it is a known constant, which is +/// the point of the demo: it exercises verify-before-mint. It is useless in +/// production, because a fixed value is not an identity and every client posts +/// the same word, so it is for demonstration and testing only. A real +/// client-side provider verifies a real payload (for example an OWID signature) +/// instead of a shared constant. +#[derive(Debug, Clone)] +#[cfg(any(test, feature = "client-fixed-demo"))] +pub struct ClientFixedProvider; + +#[cfg(any(test, feature = "client-fixed-demo"))] +impl EdgeCookieProvider for ClientFixedProvider { + fn id(&self) -> &'static str { + CLIENT_FIXED_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("cfix") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + // No identifier is derived at the edge: the value comes from the page + // script, which posts it to the resolve endpoint. + Ok(GeneratedEdgeCookie::default()) + } + + fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + ) -> Result> { + // Verify the posted value against the known shared word, then mint it as + // the Edge Cookie. A value that does not match yields no Edge Cookie. + // This stands in for a real provider's verification (for example + // checking a signature) before it trusts a client-supplied value. + let matches = core::str::from_utf8(input.payload) + .map(str::trim) + .is_ok_and(|value| value == EXPECTED_VALUE); + + Ok(GeneratedEdgeCookie { + id: matches.then(|| EXPECTED_VALUE.to_owned()), + response_headers: Vec::new(), + }) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + // The fixed word has no dot separator, so the built-in default (which + // normalizes the HMAC `.` shape) would corrupt it as a + // KV key. Like any opaque-identifier provider, the value is the key. + value.to_owned() + } + + fn required_permissions(&self) -> PermissionSet { + // The provider writes the resolved value to the device as the Edge + // Cookie, so it requires necessary.operations.storage (TCF Purpose 1), the same gate + // as the HMAC provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + /// Refuses an injected provider that claims a name core supplies itself. /// /// Two suppliers cannot own one name. Core ships the `hmac` provider, and once @@ -988,6 +1152,24 @@ fn resolve_named_provider( ))); } + // The client-fixed demonstration provider takes no configuration block and + // no services, so it is built whenever it is selected. A fixed shared word + // is not an identity, so it is compiled only into test and demonstration + // builds and a build without it refuses the name rather than substituting + // anything. `check_named_provider_configuration` refuses the same name at + // startup, so reaching this error means the two have drifted apart. + if key == CLIENT_FIXED_PROVIDER_KEY { + #[cfg(any(test, feature = "client-fixed-demo"))] + return Ok(Box::new(ClientFixedProvider)); + #[cfg(not(any(test, feature = "client-fixed-demo")))] + return Err(Report::new(TrustedServerError::EdgeCookie { + message: "The client-fixed demo Edge Cookie provider is not compiled into this \ + build. It is for demonstration and testing only; enable the \ + trusted-server-core `client-fixed-demo` cargo feature to use it" + .to_owned(), + })); + } + injected .filter(|provider| provider.id() == key) .map(|provider| Box::new(SharedProvider(provider)) as Box) @@ -1001,6 +1183,62 @@ fn resolve_named_provider( }) } +/// Checks that `key` names a provider this deployment could build, as far as +/// the configuration on its own can answer. +/// +/// The startup counterpart to [`resolve_named_provider`], and the reason +/// configuration validation does not ask the settings whether a +/// `[ec.providers.]` block is present. Whether a name needs a block is +/// the resolution's knowledge, not the settings', because a provider built from +/// nothing (the client-fixed demonstration provider) is configured correctly +/// with no block at all, while a build that does not compile that provider in +/// cannot honor the name however it is configured. Both arms live here beside +/// the resolution they belong to, and both go with it when these providers +/// become modules. +/// +/// Whether the host supplies a capability a provider needs is not answerable +/// from configuration, so it is not asked here. [`ensure_provider_available`] +/// asks that, with the services the adapter injects. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the name is not compiled +/// into this build, or when it needs an `[ec.providers.]` block that is +/// absent. +pub(crate) fn check_named_provider_configuration( + key: &str, + ec: &Ec, +) -> Result<(), Report> { + // The one name built from nothing, so a block lookup would reject the + // correctly configured case, and the one name a production build does not + // supply at all, which no amount of configuration can fix. Rejecting it + // here rather than when the provider is built means an operator finds out + // at startup instead of on the first request. + if key == CLIENT_FIXED_PROVIDER_KEY { + #[cfg(any(test, feature = "client-fixed-demo"))] + return Ok(()); + #[cfg(not(any(test, feature = "client-fixed-demo")))] + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"client-fixed\" selects the demonstration provider, \ + which is not compiled into this build. Enable the trusted-server-core \ + `client-fixed-demo` cargo feature for demonstrations" + .to_owned(), + })); + } + + // Every other name, whether core builds it or the adapter injects it, is + // configured by the `[ec.providers.]` block carrying its own name. + if ec.providers.has_block(key) { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Edge Cookie provider `{key}` is selected but has no `[ec.providers.{key}]` configuration" + ), + })) + } +} + /// Checks once, at startup, that this deployment can build the provider named /// by the `[ec] provider` selector. /// @@ -1163,6 +1401,13 @@ impl EdgeCookieProvider for SharedProvider { fn required_permissions(&self) -> PermissionSet { self.0.required_permissions() } + + fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + ) -> Result> { + self.0.resolve_from_client(input) + } } #[cfg(test)] @@ -1825,7 +2070,104 @@ mod tests { } #[test] - fn host_signal_provider_defers_without_signals() { + fn client_fixed_defers_in_generate() { + let request_info = test_request_info(); + let generated = ClientFixedProvider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_none(), + "client-fixed should defer in generate, deriving no edge identifier" + ); + } + + #[test] + fn client_fixed_mints_when_posted_word_matches() { + let input = ClientResolveInput { + payload: EXPECTED_VALUE.as_bytes(), + permissions: None, + consent: None, + }; + let generated = ClientFixedProvider + .resolve_from_client(&input) + .expect("should resolve"); + assert_eq!( + generated.id.as_deref(), + Some(EXPECTED_VALUE), + "the known shared word should verify and mint the Edge Cookie" + ); + } + + #[test] + fn client_fixed_rejects_unknown_word() { + let input = ClientResolveInput { + payload: b"not-the-word", + permissions: None, + consent: None, + }; + let generated = ClientFixedProvider + .resolve_from_client(&input) + .expect("should resolve"); + assert!( + generated.id.is_none(), + "a value that does not match the known word should mint no Edge Cookie" + ); + } + + #[test] + fn client_fixed_requires_store_on_device() { + assert!( + ClientFixedProvider + .required_permissions() + .contains(Permission::StoreOnDevice), + "client-fixed writes a cookie, so it requires necessary.operations.storage" + ); + } + + #[test] + fn server_side_provider_inherits_no_op_resolve_from_client() { + // HmacProvider does not override resolve_from_client, so it inherits the + // no-op default: a server-side provider does not participate in the + // client cycle. + let provider = HmacProvider::new(test_passphrase()); + let input = ClientResolveInput { + payload: b"anything", + permissions: None, + consent: None, + }; + let generated = provider + .resolve_from_client(&input) + .expect("should resolve"); + assert!( + generated.id.is_none(), + "a server-side provider inherits the no-op resolve_from_client default" + ); + } + + #[test] + fn the_fixed_word_and_marker_name_match_the_page_script() { + // The demo page script and this provider share the fixed word by + // convention; the resolved-marker cookie name is likewise shared with + // the script. Assert against the script source so a rename on either + // side fails this test instead of silently breaking the round trip. + let script = include_str!( + "../../../trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts" + ); + assert!( + script.contains(&format!("const FIXED_WORD = '{EXPECTED_VALUE}'")), + "the page script's FIXED_WORD should match EXPECTED_VALUE" + ); + assert!( + script.contains(&format!( + "const MARKER_COOKIE_NAME = '{}'", + crate::constants::COOKIE_TS_EC_RESOLVED + )), + "the page script's marker cookie name should match COOKIE_TS_EC_RESOLVED" + ); + } + + #[test] + fn host_signal_provider_defers_without_fingerprints() { let signals = Arc::new(TestHostSignals { ja4: None, h2: None, @@ -2045,4 +2387,71 @@ mod tests { "should pass on a host that injects host signals, whatever this request's signals are", ); } + + #[test] + fn the_configuration_check_and_the_resolution_agree_on_a_provider_with_no_block() { + // The demonstration provider is configured correctly with no + // `[ec.providers.*]` block at all, so a check that asked the settings + // whether a block was present rejected a valid deployment. The check + // asks the resolution instead, and the two must give the same answer, + // because a startup check that passes what the construction then + // refuses leaves the deployment failing on its first request. + let ec = Ec { + provider: Some(EcProviderSelection::from(CLIENT_FIXED_PROVIDER_KEY)), + ..Ec::default() + }; + + ec.validate_provider_selection() + .expect("client-fixed should validate with no configuration block"); + + let built = build_provider(&ec, None, None) + .expect("client-fixed should build with no configuration and no services") + .expect("client-fixed should yield a provider"); + assert_eq!( + built.id(), + CLIENT_FIXED_PROVIDER_KEY, + "the built provider should be the one the selector names" + ); + } + + #[test] + fn a_name_that_needs_a_block_still_fails_without_one() { + // Taking the block question out of the settings must not weaken it for + // the names that do need a block. + let ec = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + ..Ec::default() + }; + + let err = ec + .validate_provider_selection() + .expect_err("hmac with no block should still fail at startup"); + assert!( + err.to_string().contains("[ec.providers.hmac]"), + "the error should name the missing block, got: {err}" + ); + } + + #[test] + fn a_block_left_configured_alongside_a_blockless_provider_is_still_rejected() { + // The unreferenced-block rule does not soften for a provider that + // needs no block of its own: a stale block is still a mistake. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let ec = Ec { + provider: Some(EcProviderSelection::from(CLIENT_FIXED_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = ec + .validate_provider_selection() + .expect_err("a stray hmac block should still be rejected"); + assert!( + err.to_string().contains("hmac"), + "the error should name the unreferenced block, got: {err}" + ); + } } diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs new file mode 100644 index 0000000000..7d345eb797 --- /dev/null +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -0,0 +1,694 @@ +//! Client-cycle Edge Cookie resolution endpoint (`POST /_ts/api/v1/ec/resolve`). +//! +//! A client-side Edge Cookie provider defers on the organic page request +//! (deriving no identifier at the edge) and lets the page do the work in the +//! browser. When the page has its result it posts the value here, and this +//! endpoint hands it to the configured provider's +//! [`resolve_from_client`](super::provider::EdgeCookieProvider::resolve_from_client) +//! to mint the Edge Cookie. +//! +//! The endpoint is provider-agnostic: it bounds the body, gates on the +//! permission model (the same gate as organic generation), calls the provider, +//! and sets the cookie on its own response so the value is live for every +//! subsequent first-party request. Whether the posted value is trustworthy is +//! the provider's responsibility. The payload arrives from the browser, so a +//! real provider verifies it (for example an OWID signature) before minting. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::{HeaderValue, Request, Response, StatusCode, header}; + +use crate::error::TrustedServerError; +use crate::settings::Settings; + +use super::EcContext; +use super::cookies::{ + ec_id_has_only_allowed_chars, set_provider_ec_cookie, set_resolved_marker_cookie, +}; +use super::kv::KvIdentityGraph; +use super::kv_types::KvEntry; +use super::provider::{ClientResolveInput, build_provider}; + +/// Maximum size of a resolve request body. +/// +/// Client-cycle payloads (a random value, or a signed envelope such as a +/// vendor JSON payload) are small; this bound guards against an oversized body +/// before it is read into memory. +const MAX_BODY_SIZE: usize = 64 * 1024; + +/// Handles `POST /_ts/api/v1/ec/resolve`. +/// +/// The request must carry an `Origin` on the publisher's domain (this endpoint +/// sets identity state, so a foreign page must not be able to drive it) and a +/// `text/plain` or `application/json` body. Gates on the configured provider's +/// required permissions, then asks the provider to mint an Edge Cookie from +/// the posted payload. A minted identifier is persisted to the identity graph +/// before the cookie is set, so withdrawal reaches a client-set identity the +/// same way it reaches an edge-minted one; with no graph available nothing is +/// minted, matching the organic path's rule against phantom cookies. On +/// success the EC cookie and its `non-HttpOnly` resolved marker are set and the +/// status is `200`. +/// +/// Rejections: `403` for a missing or foreign `Origin`, `415` for another +/// content type, `413` for an oversized body, `400` when the provider mints an +/// identifier outside the identifier bounds, `409` when the request already +/// carries a different identity (a resolve must not silently replace one), and +/// `503` when the identity-graph write fails. When the permission gate is +/// closed, no provider is configured, no graph is available, or the provider +/// mints nothing, the response is `204` with no cookie. Every response carries +/// `Cache-Control: no-store`. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when the provider fails to process the +/// payload. A payload that is merely unverified or absent yields a `204` +/// rather than an error. +pub fn handle_ec_resolve( + settings: &Settings, + req: Request, + ec_context: &EcContext, + kv: Option<&KvIdentityGraph>, +) -> Result, Report> { + // This endpoint sets identity state from a page script, so the posted + // request must originate from the publisher's own site. Browsers always + // send `Origin` on cross-origin POSTs and on same-origin `fetch` POSTs, + // so its absence means a non-browser caller, which has no business here. + if !origin_is_publisher(&req, settings) { + log::warn!("EC resolve rejected: missing or foreign Origin"); + return Ok(status_only(StatusCode::FORBIDDEN)); + } + + // The page script posts plain text; a vendor payload may be JSON. Anything + // else is not a resolve payload. + if !content_type_is_allowed(&req) { + return Ok(status_only(StatusCode::UNSUPPORTED_MEDIA_TYPE)); + } + + // Gate: the configured provider's required permissions must be set for + // this request, the same gate as the organic generation path. A + // client-driven resolve does not bypass the permission model. + if !ec_context.ec_allowed() { + log::info!("EC resolve skipped: required permissions not set"); + return Ok(status_only(StatusCode::NO_CONTENT)); + } + + // Rebuild the provider with the same host signals captured on the context, so + // a provider that needs a service the host cannot supply fails here. The + // client value is verified from the posted body below, not from request info. + let Some(provider) = build_provider( + &settings.ec, + ec_context.host_signals(), + ec_context.ec_provider(), + )? + else { + log::info!("EC resolve skipped: no Edge Cookie provider configured"); + return Ok(status_only(StatusCode::NO_CONTENT)); + }; + + // Bound the body before reading it into memory. + if content_length_exceeds_limit(&req, MAX_BODY_SIZE) { + return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); + } + let payload = req.into_body().into_bytes().unwrap_or_default(); + if payload.len() > MAX_BODY_SIZE { + return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); + } + + let input = ClientResolveInput { + payload: payload.as_ref(), + permissions: Some(ec_context.permissions()), + consent: Some(ec_context.consent()), + }; + + let generated = provider.resolve_from_client(&input)?; + log::debug!( + "EC resolve handled (provider={}): id {}", + provider.id(), + if generated.id.is_some() { + "minted" + } else { + "not minted" + }, + ); + + let generated_id = generated + .id + .map(|value| super::provider::apply_provider_code(provider.as_ref(), &value)); + let Some(ec_id) = generated_id else { + let mut response = status_only(StatusCode::NO_CONTENT); + for (name, value) in generated.response_headers { + response.headers_mut().insert(name, value); + } + return Ok(response); + }; + + // The same identifier bounds as the organic mint path: reject, never + // rewrite. A provider that minted an out-of-bounds identifier is a bad + // request from the client's perspective, because the posted payload + // produced an unusable identity. + if !ec_id_has_only_allowed_chars(&ec_id) { + log::error!( + "EC resolve rejected: provider `{}` minted an identifier outside the bounds", + provider.id(), + ); + return Ok(status_only(StatusCode::BAD_REQUEST)); + } + + // A resolve must not silently replace an identity the request already + // carries. The page script does not post when an identity exists, so a + // different minted identifier here is a conflict to surface, not paper + // over. + if let Some(existing) = ec_context.ec_value() + && existing != ec_id + { + log::warn!( + "EC resolve rejected: request already carries a different identity (provider={})", + provider.id(), + ); + return Ok(status_only(StatusCode::CONFLICT)); + } + + // Persist the identity-graph row before setting the cookie, keyed by the + // provider's canonical form, exactly like the organic mint path. Without + // a row, withdrawal could never reach this identity, so with no graph + // available nothing is minted (the organic path applies the same rule + // against phantom cookies). + let Some(graph) = kv else { + log::warn!("EC resolve skipped: no identity graph available, so no cookie is minted"); + return Ok(status_only(StatusCode::NO_CONTENT)); + }; + let now = super::current_timestamp(); + let mut entry = KvEntry::new( + ec_context.consent(), + ec_context.geo_info(), + now, + &settings.publisher.domain, + ); + entry.device = ec_context + .device_signals() + .map(super::device::DeviceSignals::to_kv_device); + let kv_key = super::provider::provider_kv_key(provider.as_ref(), &ec_id); + if let Err(err) = graph.create_or_revive(&kv_key, &entry) { + log::error!("EC resolve failed to write the identity-graph row: {err:?}"); + return Ok(status_only(StatusCode::SERVICE_UNAVAILABLE)); + } + + let mut response = status_only(StatusCode::OK); + + // Apply any response headers the provider asked for (for example to request + // more client evidence on a later request). Empty for the demo provider. + for (name, value) in generated.response_headers { + response.headers_mut().insert(name, value); + } + + set_provider_ec_cookie(settings, &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); + + Ok(response) +} + +/// Whether the request's `Origin` names the publisher's domain (exactly, or a +/// subdomain of it). +fn origin_is_publisher(req: &Request, settings: &Settings) -> bool { + let Some(origin) = req + .headers() + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let Some(host) = origin + .strip_prefix("https://") + .or_else(|| origin.strip_prefix("http://")) + else { + return false; + }; + let host = host.split(':').next().unwrap_or(host); + let publisher = settings.publisher.domain.as_str(); + host.eq_ignore_ascii_case(publisher) + || host + .to_ascii_lowercase() + .ends_with(&format!(".{}", publisher.to_ascii_lowercase())) +} + +/// Whether the request's `Content-Type` is one a resolve payload may use. +fn content_type_is_allowed(req: &Request) -> bool { + req.headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or(value).trim()) + .is_some_and(|media_type| { + media_type.eq_ignore_ascii_case("text/plain") + || media_type.eq_ignore_ascii_case("application/json") + }) +} + +/// Builds a bodiless response with the given status. Identity-resolution +/// responses must never be cached by the browser or an intermediary. +fn status_only(status: StatusCode) -> Response { + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = status; + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// Returns `true` when the request advertises a `Content-Length` over `limit`. +fn content_length_exceeds_limit(req: &Request, limit: usize) -> bool { + req.headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|len| len > limit) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::types::ConsentContext; + use crate::ec::provider::{ + CLIENT_FIXED_PROVIDER_KEY, EcProviderSelection, EdgeCookieProvider, GeneratedEdgeCookie, + IdentityInput, + }; + use crate::evidence::RequestInfo; + use crate::platform::test_support::noop_services_with_ec_provider; + use crate::test_support::tests::create_test_settings; + use http::Method; + use std::sync::Arc; + + fn settings_with_client_fixed() -> Settings { + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from(CLIENT_FIXED_PROVIDER_KEY)); + settings + } + + // The fixed word shared by the client-fixed provider and its page script. + const FIXED_WORD: &str = "an-ec"; + + fn post(body: &str) -> Request { + post_with(Some("https://test-publisher.com"), Some("text/plain"), body) + } + + fn post_with( + origin: Option<&str>, + content_type: Option<&str>, + body: &str, + ) -> Request { + let mut builder = Request::builder() + .method(Method::POST) + .uri("https://test-publisher.com/_ts/api/v1/ec/resolve"); + if let Some(origin) = origin { + builder = builder.header(header::ORIGIN, origin); + } + if let Some(content_type) = content_type { + builder = builder.header(header::CONTENT_TYPE, content_type); + } + builder + .body(EdgeBody::from(body.to_owned())) + .expect("should build resolve request") + } + + fn in_memory_graph() -> crate::ec::kv::KvIdentityGraph { + crate::ec::kv::KvIdentityGraph::in_memory("test-ec-store") + } + + fn gated(ec_allowed: bool) -> EcContext { + EcContext::new_for_test_gated(None, ConsentContext::default(), ec_allowed) + } + + /// Returns whether `value` is a canonical UUID (`8-4-4-4-12` lowercase hex). + fn is_uuid(value: &str) -> bool { + let groups = [8, 4, 4, 4, 12]; + let parts: Vec<&str> = value.split('-').collect(); + parts.len() == groups.len() + && parts.iter().zip(groups).all(|(part, len)| { + part.len() == len + && part + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) + } + + /// A **test-only** provider modeling a client-generated, first-party + /// identifier (a `UUID`) that the browser mints and posts back for the server + /// to set. It is not a production provider and exists only to exercise the + /// client-set Edge Cookie value path from end to end. The edge defers, the + /// page posts a value, and it must round-trip as the cookie and the KV key. + /// + /// A `UUID` has no separator, so the built-in [`normalize_id_for_kv`] default + /// would append a trailing dot and corrupt it, which is why this provider + /// (like any opaque-identifier provider) returns the value unchanged. + /// + /// [`normalize_id_for_kv`]: EdgeCookieProvider::normalize_id_for_kv + #[derive(Debug)] + struct TestIdProvider; + + impl EdgeCookieProvider for TestIdProvider { + fn id(&self) -> &'static str { + "testid" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0id") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + // The identifier is minted in the browser, so the edge derives nothing. + Ok(GeneratedEdgeCookie::default()) + } + + fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + ) -> Result> { + // The page posts the identifier it generated. Accept a well-formed UUID. + let value = core::str::from_utf8(input.payload) + .unwrap_or_default() + .trim(); + if is_uuid(value) { + Ok(GeneratedEdgeCookie { + id: Some(value.to_owned()), + response_headers: Vec::new(), + }) + } else { + Ok(GeneratedEdgeCookie::default()) + } + } + + fn accepts_id(&self, value: &str) -> bool { + is_uuid(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn client_set_value_round_trips_through_the_ec_scenario() { + // A client-generated first-party UUID, the value the browser posts back. + const TEST_ID: &str = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + + let provider: Arc = Arc::new(TestIdProvider); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("testid")); + let services = noop_services_with_ec_provider(Arc::clone(&provider)); + + // 1. Organic first visit: no EC yet, and the edge defers because the + // identifier is generated in the browser, not derived server-side. + let organic = Request::builder() + .method(Method::GET) + .uri("https://edge.example.com/") + .body(EdgeBody::empty()) + .expect("should build organic request"); + let mut ec = EcContext::read_from_request(&settings, &organic, &services) + .expect("should read EC context"); + assert!( + ec.ec_value().is_none(), + "no EC should exist on the first visit" + ); + ec.generate_if_needed(&settings, None) + .expect("should run generation"); + assert!( + ec.ec_value().is_none(), + "a client-set provider defers minting to the browser" + ); + + // 2. The page generates its identifier and posts it to the resolve + // endpoint; the server persists the identity-graph row and sets the + // value as the EC cookie. + const CODED_TEST_ID: &str = "t0id~3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + let graph = in_memory_graph(); + let response = handle_ec_resolve(&settings, post(TEST_ID), &ec, Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::OK, + "a valid client-set value should return 200" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be utf-8"); + assert!( + set_cookie.contains(CODED_TEST_ID), + "the EC cookie should carry the coded client-set identifier, got {set_cookie}" + ); + assert!( + graph + .get(CODED_TEST_ID) + .expect("should read the graph") + .is_some(), + "the resolve should persist the identity-graph row, so withdrawal can reach it" + ); + + // 3. A later request carries the EC cookie; the server reads the + // identifier back verbatim. This is the step the built-in shape check + // used to drop. + let ret = Request::builder() + .method(Method::GET) + .uri("https://edge.example.com/") + .header("cookie", format!("ts-ec={CODED_TEST_ID}")) + .body(EdgeBody::empty()) + .expect("should build return request"); + let ec2 = EcContext::read_from_request(&settings, &ret, &services) + .expect("should read EC context"); + assert_eq!( + ec2.ec_value(), + Some(CODED_TEST_ID), + "the client-set identifier should round-trip as the coded EC value" + ); + } + + #[test] + fn resolve_sets_cookie_marker_and_no_store_when_word_matches_and_allowed() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let response = handle_ec_resolve(&settings, post(FIXED_WORD), &gated(true), Some(&graph)) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::OK, + "a verified value should return 200" + ); + let cookies: Vec = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should be utf-8").to_owned()) + .collect(); + let ec_cookie = cookies + .iter() + .find(|cookie| cookie.starts_with("ts-ec=")) + .expect("should set the EC cookie"); + assert!( + ec_cookie.contains("cfix~an-ec"), + "should set the coded verified word as the EC cookie, got {ec_cookie}" + ); + assert!( + ec_cookie.contains("HttpOnly"), + "the EC cookie should be HttpOnly" + ); + assert!( + ec_cookie.contains("Secure"), + "the EC cookie should be Secure" + ); + let marker = cookies + .iter() + .find(|cookie| cookie.starts_with("ts-ecr=1")) + .expect("should set the resolved marker cookie"); + assert!( + !marker.contains("HttpOnly"), + "the marker must be readable by the page script, so not HttpOnly" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "identity responses must never be cached" + ); + assert!( + graph + .get("cfix~an-ec") + .expect("should read the graph") + .is_some(), + "the resolve should persist the identity-graph row under the coded key" + ); + } + + #[test] + fn resolve_returns_204_when_not_allowed() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let response = handle_ec_resolve(&settings, post(FIXED_WORD), &gated(false), Some(&graph)) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "a closed permission gate should return 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a closed gate should set no cookie" + ); + } + + #[test] + fn resolve_returns_204_when_no_provider_configured() { + let mut settings = create_test_settings(); + settings.ec.provider = None; + let graph = in_memory_graph(); + let response = handle_ec_resolve(&settings, post("123"), &gated(true), Some(&graph)) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "no configured provider should return 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "no configured provider should set no cookie" + ); + } + + #[test] + fn resolve_rejects_oversized_body() { + let settings = settings_with_client_fixed(); + let big = "x".repeat(MAX_BODY_SIZE + 1); + let graph = in_memory_graph(); + let response = handle_ec_resolve(&settings, post(&big), &gated(true), Some(&graph)) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "an oversized body should be rejected with 413" + ); + } + + #[test] + fn resolve_rejects_a_missing_or_foreign_origin() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + for origin in [None, Some("https://attacker.example")] { + let request = post_with(origin, Some("text/plain"), FIXED_WORD); + let response = handle_ec_resolve(&settings, request, &gated(true), Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "an identity-setting POST must come from the publisher's own site" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a rejected origin must set no cookie" + ); + } + } + + #[test] + fn resolve_accepts_a_publisher_subdomain_origin() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let request = post_with( + Some("https://www.test-publisher.com"), + Some("text/plain"), + FIXED_WORD, + ); + let response = handle_ec_resolve(&settings, request, &gated(true), Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::OK, + "a subdomain of the publisher should be accepted" + ); + } + + #[test] + fn resolve_rejects_an_unexpected_content_type() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let request = post_with( + Some("https://test-publisher.com"), + Some("application/x-www-form-urlencoded"), + FIXED_WORD, + ); + let response = handle_ec_resolve(&settings, request, &gated(true), Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "only text/plain and application/json bodies are resolve payloads" + ); + } + + #[test] + fn resolve_conflicts_when_a_different_identity_already_exists() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let existing = format!("{}.ABC123", "e".repeat(64)); + let ec_context = + EcContext::new_for_test_gated(Some(existing), ConsentContext::default(), true); + let response = handle_ec_resolve(&settings, post(FIXED_WORD), &ec_context, Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "a resolve must not silently replace an existing identity" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a conflict must set no cookie" + ); + } + + #[test] + fn resolve_mints_nothing_without_an_identity_graph() { + let settings = settings_with_client_fixed(); + let response = handle_ec_resolve(&settings, post(FIXED_WORD), &gated(true), None) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "with no graph there is no row to persist, so nothing is minted" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a cookie without a graph row would be a phantom identity" + ); + } + + #[test] + fn resolve_sets_no_cookie_for_unmatched_word() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let response = + handle_ec_resolve(&settings, post("not-the-word"), &gated(true), Some(&graph)) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "a value that fails verification should yield no cookie and a 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an unmatched value should set no cookie" + ); + } +} diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index fb9a3dd911..70005b210b 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -718,6 +718,10 @@ struct IntegrationRegistryInner { html_post_processors: Vec>, head_injectors: Vec>, request_filters: Vec>, + /// JS module IDs to include in the bundle that come from a source other than + /// a registered integration, for example a module tied to the selected Edge + /// Cookie provider. Populated in [`IntegrationRegistry::new`] from settings. + extra_js_module_ids: Vec<&'static str>, } impl Default for IntegrationRegistryInner { @@ -739,6 +743,7 @@ impl Default for IntegrationRegistryInner { html_post_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), + extra_js_module_ids: Vec::new(), } } } @@ -876,6 +881,17 @@ impl IntegrationRegistry { } } + // A client-cycle Edge Cookie provider ships a page script that posts its + // result to the resolve endpoint. The script rides the tsjs bundle, so + // include its module when that provider is selected. The same module + // list drives both the served bundle and the injected `