diff --git a/AGENTS.md b/AGENTS.md index fe44a130ab..2c4dc80ff3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -388,7 +388,7 @@ deployment selects an implementation and the core stays neutral: | Capability | Trait | Selector | Built-in (core) | Vendor / host crates | | --------------------- | ----------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------- | -| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | +| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | HMAC, client_fixed (opt-in, no default) | `crates/edgecookie/` | | Device detection | `DeviceModule` (`ec/device.rs`) | `[device] module` | User-Agent only (default) | `crates/device/` | | Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] module` | Disabled, no location (default) | `crates/geo/` | | Permission signals | `PermissionSignalModule` (`permission_signal/mod.rs`) | `[permission-signal] modules` (an ordered list) | None, and with no module every permission stays at its country and region baseline | `crates/permission-signal/` | diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 9771f4cd2d..10efd42c63 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`] | @@ -116,6 +117,7 @@ use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::module::request_module; use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::ec::resolve::handle_ec_resolve; use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -128,8 +130,6 @@ use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformKvStore, PolicyOrigin, RuntimeServices, StoreName, build_geo_module, }; -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, @@ -146,6 +146,7 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::{ProxyAssetRoute, Settings}; use trusted_server_core::settings_data::{DEFAULT_CONFIG_STORE_ID, 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::{ @@ -797,6 +798,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 + // creating, so it takes the same bot-gated graph as generation: an + // unrecognized client gets no graph, and therefore no new Edge Cookie. + handle_ec_resolve(&state.settings, req, &ec.ec_context, ec.kv_graph.as_ref()) + } NamedRouteHandler::Auction => { ec.ec_context.set_eid_sync_source(EidSyncSource::Auction); let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; @@ -1223,6 +1230,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + EcResolve, Auction, PageBids, FirstPartyProxy, @@ -1337,6 +1345,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], @@ -2461,6 +2474,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 1446b7ee05..618f3664a1 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -295,7 +295,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 10431f25fa..9aaebd2fc1 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -64,6 +64,9 @@ uuid = { workspace = true, features = ["js"] } chrono = { workspace = true, features = ["wasmbind"] } [features] +# Compiles in the `client_fixed` demonstration Edge Cookie module. 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/config.rs b/crates/trusted-server-core/src/config.rs index 570f714a11..9587672e6f 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -375,6 +375,7 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report) 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. + expire_ec_resolved_marker(settings, response); +} + +/// Expires the resolved marker on its own, leaving the Edge Cookie alone. +/// +/// The marker records only that some client-cycle module resolved in the +/// past. It is not namespaced by the module code the way the cookie value, +/// the identity-graph key and withdrawal are, so on a switch from one +/// client-cycle module to another the marker outlives the identity it was +/// set for. The new module's page script would then see the marker, skip the +/// resolve it should perform, and the visitor would sit with no identity +/// 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) { + 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)] @@ -194,91 +249,68 @@ mod tests { ); } + /// Every value in the identifier alphabet is written verbatim, whatever + /// module made it, with the standard security attributes. #[test] - fn set_ec_cookie_appends_header() { - let settings = create_test_settings(); - let mut response = empty_response(); - set_ec_cookie(&settings, &mut response, TEST_EC_ID); - - let cookie_header = response - .headers() - .get(header::SET_COOKIE) - .expect("should have Set-Cookie header"); - let cookie_str = cookie_header.to_str().expect("should be valid UTF-8"); - - assert_eq!( - cookie_str, - create_ec_cookie(&settings, TEST_EC_ID), - "should match create_ec_cookie output" - ); - } - - #[test] - fn set_ec_cookie_rejects_disallowed_chars_outright() { - // Rejection, never rewriting: an identifier outside the alphabet must - // not produce a cookie at all, so the cookie value and the identity - // graph key can never silently diverge. - let settings = create_test_settings(); - let mut response = Response::new(EdgeBody::empty()); - set_ec_cookie( - &settings, - &mut response, - "evil;injected -foo=bar", - ); - assert!( - response.headers().get(header::SET_COOKIE).is_none(), - "an identifier outside the alphabet should set no cookie" - ); - } - - #[test] - fn create_ec_cookie_preserves_well_formed_id() { - let settings = create_test_settings(); - let id = "abc123def0123456789abcdef0123456789abcdef0123456789abcdef01234567.xk92ab"; - let result = create_ec_cookie(&settings, id); - let value = result - .strip_prefix(&format!("{COOKIE_TS_EC}=")) - .and_then(|s| s.split_once(';').map(|(v, _)| v)) - .expect("should have cookie value portion"); - - assert_eq!(value, id, "should not modify a well-formed EC ID"); - } - - #[test] - fn set_ec_cookie_rejects_semicolon() { + fn set_ec_cookie_writes_an_identifier_in_the_alphabet_verbatim() { let settings = create_test_settings(); - let mut response = empty_response(); - set_ec_cookie(&settings, &mut response, "evil; Domain=.attacker.com"); - - assert!( - response.headers().get(header::SET_COOKIE).is_none(), - "should not set Set-Cookie when value contains a semicolon" - ); + for (case, value) in [ + ("an HMAC identifier", TEST_EC_ID), + ( + "another HMAC identifier", + "abc123def0123456789abcdef0123456789abcdef0123456789abcdef01234567.xk92ab", + ), + ("a module's opaque value", "8473625190"), + // Unpadded base64url (RFC 4648 section 5) fits the alphabet, so a + // module that carries binary data encodes it that way. + ("a base64url value", "abcDEF123-_x"), + ] { + let mut response = empty_response(); + + set_ec_cookie(&settings, &mut response, value); + + let cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap_or_else(|| panic!("{case}: should set the cookie")) + .to_str() + .expect("should be valid UTF-8"); + assert_eq!( + cookie, + format!( + "{}={value}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly", + COOKIE_TS_EC, settings.publisher.domain, COOKIE_MAX_AGE, + ), + "{case}" + ); + assert_eq!(cookie, create_ec_cookie(&settings, value), "{case}"); + } } + /// A value outside the alphabet sets no cookie at all, never a rewritten + /// one, so the cookie and the identity graph key cannot diverge and no + /// value can inject an attribute or a header. #[test] - fn set_ec_cookie_rejects_crlf() { + fn set_ec_cookie_refuses_a_value_outside_the_alphabet() { let settings = create_test_settings(); - let mut response = empty_response(); - set_ec_cookie(&settings, &mut response, "evil\r\nX-Injected: header"); - - assert!( - response.headers().get(header::SET_COOKIE).is_none(), - "should not set Set-Cookie when value contains CRLF" - ); - } + for (case, value) in [ + ("a semicolon", "evil; Domain=.attacker.com"), + ( + "a carriage return and line feed", + "evil\r\nX-Injected: header", + ), + ("a line feed", "evil;injected\nfoo=bar"), + ("a space", "bad value"), + ] { + let mut response = empty_response(); - #[test] - fn set_ec_cookie_rejects_space() { - let settings = create_test_settings(); - let mut response = empty_response(); - set_ec_cookie(&settings, &mut response, "bad value"); + set_ec_cookie(&settings, &mut response, value); - assert!( - response.headers().get(header::SET_COOKIE).is_none(), - "should not set Set-Cookie when value contains whitespace" - ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "{case}: should set no cookie" + ); + } } #[test] diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index ac004d4b8a..f31693f421 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -12,7 +12,7 @@ use crate::constants::EC_RESPONSE_HEADERS; use crate::settings::Settings; use super::EcContext; -use super::cookies::{expire_ec_cookie, set_ec_cookie}; +use super::cookies::{expire_ec_cookie, expire_ec_resolved_marker, set_ec_cookie}; use super::kv::{ CreateIfAbsentOutcome, EidCookieSyncOutcome, KvIdentityGraph, PartnerIdUpdate, apply_partner_id_updates, @@ -86,6 +86,19 @@ pub fn ec_finalize_response( return; } + // The request carried a `ts-ec` 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 + // page script that a resolve had already happened, leaving the visitor with + // no identity instead of a restarted one. Expire the marker so the cycle + // starts again. The cookie itself is left alone, because the value is + // 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); + } + // Returning user: EC is permitted and came from the request. if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { // Key the snapshot, EID ingestion and orphan recovery by the module's @@ -2752,6 +2765,78 @@ mod tests { "withdrawal should still expire the browser cookie after a switch, got {cookies:?}" ); } + #[test] + fn marker_is_expired_when_the_selected_module_does_not_own_the_cookie() { + // A switch between client-cycle modules looks like this on the first + // request after the switch. The raw cookie is still presented, but the + // selected module does not recognize it, so no EC is in play. + let settings = create_test_settings(); + let mut ec_context = make_context( + None, + Some("other~an-ec"), + false, + false, + Jurisdiction::Unknown, + true, + ); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &mut ec_context, + None, + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let expired = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .any(|v| v.starts_with("ts-ecr=") && v.contains("Max-Age=0")); + assert!( + expired, + "the resolved marker should be expired so the new module's page script resolves again" + ); + } + + #[test] + fn marker_is_left_alone_when_the_module_owns_the_cookie() { + let settings = create_test_settings(); + let mut ec_context = make_context( + Some("an-ec"), + Some("an-ec"), + true, + false, + Jurisdiction::Unknown, + true, + ); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &mut ec_context, + None, + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let expired = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .any(|v| v.starts_with("ts-ecr=") && v.contains("Max-Age=0")); + assert!( + !expired, + "a recognized identifier should leave the resolved marker in place" + ); + } // ----------------------------------------------------------------------- // Orphan-recovery gating and two-ID withdrawal diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 8f49fbfa97..c02bf79ba8 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -55,6 +55,7 @@ pub mod pull_sync; pub(crate) mod pull_sync_marker; pub mod rate_limiter; pub mod registry; +pub mod resolve; /// Characters of an identifier kept when redacting it for a log. const LOG_ID_PREFIX_CHARS: usize = 8; diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index 59a5dfdfd0..87bcf898c6 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -128,6 +128,17 @@ pub const HMAC_MODULE_KEY: &str = "hmac"; /// arm when the host-signal module becomes a module of its own. pub const HOST_SIGNALS_MODULE_KEY: &str = "host_signals"; +/// The implementation id of the `client_fixed` demonstration module. +/// +/// Defined in every build, though the module is compiled in only under the +/// `client-fixed-demo` cargo feature, so a build without it still recognizes +/// the name and refuses the selection at startup. The resolution in +/// [`build_module`] matches it, as does its startup counterpart +/// `check_named_module_configuration`, and the integration registry adds the +/// client-cycle page-script module when the name is selected. It is also +/// `ClientFixedModule`'s `id`. +pub const CLIENT_FIXED_MODULE_KEY: &str = "client_fixed"; + /// The type folder of every Edge Cookie crate, which a name written in /// `[ec] module` or an `implementation` line may leave off. pub const MODULE_TYPE: &str = "edgecookie"; @@ -136,8 +147,14 @@ pub const MODULE_TYPE: &str = "edgecookie"; /// [`resolve_named_module`]. /// /// [`build_module`] refuses an injected module under one of these ids -/// rather than picking one of the two. -const BUILTIN_MODULE_KEYS: &[&str] = &[HMAC_MODULE_KEY, HOST_SIGNALS_MODULE_KEY]; +/// rather than picking one of the two. [`CLIENT_FIXED_MODULE_KEY`] is listed +/// whether or not the demonstration module is compiled in, because a build +/// without it still owns the name. +const BUILTIN_MODULE_KEYS: &[&str] = &[ + HMAC_MODULE_KEY, + HOST_SIGNALS_MODULE_KEY, + CLIENT_FIXED_MODULE_KEY, +]; /// The registry code of the built-in HMAC module. /// @@ -169,6 +186,31 @@ pub struct IdentityInput<'a> { pub consent: Option<&'a ConsentContext>, } +/// Inputs available to [`EdgeCookieModule::resolve_from_client`]. +/// +/// Carries the value a client produced and posted to the Edge Cookie resolve +/// endpoint, alongside the same gating context as [`IdentityInput`]. The posted +/// value reaches the module as [`payload`](Self::payload), not through +/// anything injected into the module. 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 + /// module this is its own JSON envelope; for the built-in + /// `ClientFixedModule` 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 module's required permissions are set, so a + /// module reads this only for behavior beyond gating. + pub permissions: Option<&'a PermissionState>, + + /// The resolve request's consent context, for module-specific logic. The + /// core gates on permissions, not consent. + pub consent: Option<&'a ConsentContext>, +} + /// The outcome of [`EdgeCookieModule::generate`]. /// /// Carries the derived identifier, if any, and any response headers the module @@ -565,6 +607,12 @@ impl<'a> AcceptedModules<'a> { /// - **Server-side** (for example [`HmacModule`]): 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 `ClientFixedModule`): defers in +/// [`generate`](Self::generate) (returns `id: None`), runs its own JavaScript +/// in the browser, and creates the identifier from the value the page posts +/// back in +/// [`resolve_from_client`](Self::resolve_from_client), whose response sets the +/// cookie. /// /// A module that cannot derive an identifier at the edge returns a /// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so @@ -608,6 +656,10 @@ pub trait EdgeCookieModule: 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 module creates here. A client-side module defers here + /// (returns `id: None`) and creates later in + /// [`resolve_from_client`](Self::resolve_from_client) from the value the page + /// posts back. /// /// # Errors /// @@ -670,6 +722,30 @@ pub trait EdgeCookieModule: 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 + /// module 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 module that creates entirely server-side + /// (such as [`HmacModule`]) 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 module. @@ -805,6 +881,99 @@ impl EdgeCookieModule for HostSignalModule { } } +/// The fixed, known word shared by [`ClientFixedModule`] and its page script. +/// +/// Kept cookie-safe (no characters [`set_ec_cookie`] would reject) so +/// it can be used as the Edge Cookie value verbatim. The page script posts this +/// exact string; the module creates only when the posted value matches. The +/// client copy lives in +/// `crates/trusted-server-js/lib/src/integrations/ec_client_fixed`. +/// +/// [`set_ec_cookie`]: super::cookies::set_ec_cookie +#[cfg(any(test, feature = "client-fixed-demo"))] +const EXPECTED_VALUE: &str = "an-ec"; + +/// A demonstration client-side module, 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 module creates the +/// Edge Cookie only when the posted value matches. It defers from +/// [`generate`](EdgeCookieModule::generate) so the page renders with no Edge +/// Cookie until the client reports back, then verifies and creates in +/// [`resolve_from_client`](EdgeCookieModule::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-create. 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 module 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 ClientFixedModule; + +#[cfg(any(test, feature = "client-fixed-demo"))] +impl EdgeCookieModule for ClientFixedModule { + fn id(&self) -> &'static str { + CLIENT_FIXED_MODULE_KEY + } + + fn code(&self) -> ModuleCode { + crate::module_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 create it as + // the Edge Cookie. A value that does not match yields no Edge Cookie. + // This stands in for a real module'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 accepts_id(&self, value: &str) -> bool { + // The built-in default accepts only the HMAC `.` shape, + // so the fixed word would never be read back, keyed or withdrawn. The + // fixed word is the only identifier this module creates, so it is the + // only one it owns. + value == EXPECTED_VALUE + } + + 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 module, the value is the key. + value.to_owned() + } + + fn required_permissions(&self) -> PermissionSet { + // The module 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 module. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + /// Refuses an injected module that claims an implementation core supplies /// itself. /// @@ -955,6 +1124,24 @@ fn resolve_named_module( ))); } + // The `client_fixed` demonstration module 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_module_configuration` refuses the same name at + // startup, so reaching this error means the two have drifted apart. + if implementation == CLIENT_FIXED_MODULE_KEY { + #[cfg(any(test, feature = "client-fixed-demo"))] + return Ok(Box::new(ClientFixedModule)); + #[cfg(not(any(test, feature = "client-fixed-demo")))] + return Err(Report::new(TrustedServerError::EdgeCookie { + message: "The `client_fixed` demo Edge Cookie module 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(), + })); + } + let known = known_implementations(injected.as_deref()); injected .filter(|module| { @@ -972,6 +1159,52 @@ fn resolve_named_module( }) } +/// Checks that `implementation` names a module this build compiles in, as +/// far as the configuration on its own can answer. +/// +/// The startup counterpart to [`resolve_named_module`], and the reason +/// configuration validation does not decide on its own whether every selection +/// can be honored. Whether a name is compiled into this build is the +/// resolution's knowledge, not the settings', because a build that does not +/// compile the `client_fixed` demonstration module in cannot honor that name +/// however it is configured. The arm lives here beside the resolution it +/// belongs to, and goes with it when that module becomes a module. +/// +/// Whether a selection needs a settings block is answered by +/// [`Ec::validate_module_selection`], which knows which implementations +/// built into core take settings. The demonstration module takes none, so it +/// is configured correctly with no block at all. +/// +/// Whether the host supplies a capability a module needs is not answerable +/// from configuration, so it is not asked here. [`ensure_module_available`] +/// asks that, with the services the adapter injects. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when `implementation` is not +/// compiled into this build. +pub(crate) fn check_named_module_configuration( + implementation: &str, +) -> Result<(), Report> { + // 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 + // module is built means an operator finds out at startup instead of on + // the first request. + if implementation == CLIENT_FIXED_MODULE_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] module = \"client_fixed\" selects the demonstration module, \ + which is not compiled into this build. Enable the trusted-server-core \ + `client-fixed-demo` cargo feature for demonstrations" + .to_owned(), + })); + } + + Ok(()) +} + /// The implementations this deployment could build, for an error that has just /// refused one it could not. /// @@ -1149,6 +1382,13 @@ impl EdgeCookieModule for SharedModule { 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)] @@ -1913,7 +2153,127 @@ mod tests { } #[test] - fn host_signal_module_defers_without_signals() { + fn client_fixed_defers_in_generate() { + let request_info = test_request_info(); + let generated = ClientFixedModule + .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 resolve_from_client_creates_only_from_a_verified_payload() { + // `client_fixed` creates the Edge Cookie only from the known shared + // word. `HmacModule` does not override `resolve_from_client`, so it + // inherits the no-op default, because a server-side module takes no + // part in the client cycle. + /// One case: its name, the module, the posted payload and the + /// identifier the module should create. + type Case<'a> = (&'a str, &'a dyn EdgeCookieModule, &'a [u8], Option<&'a str>); + + let hmac = HmacModule::new(test_passphrase()); + let cases: [Case<'_>; 3] = [ + ( + "client_fixed with the known word", + &ClientFixedModule, + EXPECTED_VALUE.as_bytes(), + Some(EXPECTED_VALUE), + ), + ( + "client_fixed with another word", + &ClientFixedModule, + b"not-the-word", + None, + ), + ("hmac with any payload", &hmac, b"anything", None), + ]; + for (case, module, payload, expected) in cases { + let input = ClientResolveInput { + payload, + permissions: None, + consent: None, + }; + let generated = module + .resolve_from_client(&input) + .unwrap_or_else(|err| panic!("{case}: should resolve, got: {err}")); + assert_eq!( + generated.id.as_deref(), + expected, + "{case}: should create exactly what the module verifies" + ); + } + } + + #[test] + fn client_fixed_owns_the_one_identifier_it_creates() { + // Read-back, the identity-graph key and withdrawal all ask the + // selected module whether it owns the cookie that came back. + let coded = format!("cfix~{EXPECTED_VALUE}"); + assert!( + module_owns_id(&ClientFixedModule, &coded), + "`client_fixed` should own the coded fixed word it creates" + ); + let hmac_shaped = format!("cfix~{}.ABC123", "a".repeat(64)); + for other in [ + "cfix~another-word", + EXPECTED_VALUE, + "hmac~an-ec", + hmac_shaped.as_str(), + ] { + assert!( + !module_owns_id(&ClientFixedModule, other), + "`client_fixed` should not own {other}" + ); + } + } + + #[test] + fn client_fixed_requires_store_on_device() { + assert!( + ClientFixedModule + .required_permissions() + .contains(Permission::StoreOnDevice), + "`client_fixed` writes a cookie, so it requires necessary.operations.storage" + ); + } + + #[test] + fn the_fixed_word_and_marker_name_match_the_page_script() { + // The demo page script and this module 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" + ); + // The page module declares the same permission the server-side + // module requires, and checks it against the state the page is + // handed, so the two declarations must name the same Data Use. + assert!( + script.contains(&format!( + "const REQUIRED_PERMISSION = '{}'", + Permission::StoreOnDevice.as_str() + )), + "the page script's REQUIRED_PERMISSION should match the module's declaration" + ); + } + + #[test] + fn host_signal_module_defers_without_fingerprints() { let signals = Arc::new(TestHostSignals { ja4: None, h2: None, @@ -2136,4 +2496,65 @@ 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_module_with_no_block() { + // The demonstration module is configured correctly with no + // `[ec.]` block at all, so a check that demanded a block for + // every selection rejected a valid deployment. The settings ask for a + // block only from the implementations they know take settings, and + // that has to agree with the resolution, because a startup check that + // passes what the construction then refuses leaves the deployment + // failing on its first request. + let ec = Ec { + module: Some(EcModuleSelection::from(CLIENT_FIXED_MODULE_KEY)), + ..Ec::default() + }; + + ec.validate_module_selection() + .expect("`client_fixed` should validate with no configuration block"); + + let built = build_module(&ec, None, None) + .expect("`client_fixed` should build with no configuration and no services") + .expect("`client_fixed` should yield a module"); + assert_eq!( + built.id(), + CLIENT_FIXED_MODULE_KEY, + "the built module 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 { + module: Some(EcModuleSelection::from(HMAC_MODULE_KEY)), + ..Ec::default() + }; + + let err = ec + .validate_module_selection() + .expect_err("hmac with no block should still fail at startup"); + assert!( + err.to_string().contains("[ec.hmac]"), + "the error should name the missing block, got: {err}" + ); + } + + #[test] + fn a_block_left_configured_alongside_a_blockless_module_is_still_rejected() { + // The unreferenced-block rule does not soften for a module that + // needs no block of its own, because a stale block is still a mistake. + let mut ec = selected_hmac(HMAC_MODULE_KEY); + ec.module = Some(EcModuleSelection::from(CLIENT_FIXED_MODULE_KEY)); + + let err = ec + .validate_module_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..dac25c393d --- /dev/null +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -0,0 +1,1463 @@ +//! Client-cycle Edge Cookie resolution endpoint (`POST /_ts/api/v1/ec/resolve`). +//! +//! A client-side Edge Cookie module 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 module's +//! [`resolve_from_client`](super::module::EdgeCookieModule::resolve_from_client) +//! to create the Edge Cookie. +//! +//! The endpoint is module-agnostic: it bounds the body, gates on the +//! permission model (the same gate as organic generation), calls the module, +//! 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 module's responsibility. The payload arrives from the browser, so a +//! real module verifies it (for example an OWID signature) before creating one. + +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_ec_cookie, set_resolved_marker_cookie}; +use super::kv::KvIdentityGraph; +use super::kv_types::KvEntry; +use super::module::{ClientResolveInput, apply_module_response_headers}; + +/// 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 module's +/// required permissions, then asks the module to create an Edge Cookie from +/// the posted payload. A created 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-created one. With no graph available this +/// endpoint sets no cookie at all, which is stricter than the organic +/// generation path, where the identifier is committed and only the row write +/// is skipped. 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 module creates 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 module is configured, no graph is available, or the module +/// creates nothing, the response is `204` with no cookie. Every response this +/// handler builds carries `Cache-Control: no-store`; a module or +/// configuration error propagates to the adapter's error response instead, +/// and so does a module asking for a response header inside core's reserved +/// surface, which is a broken module contract rather than a bad request. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when the module fails to process the +/// payload, or asks for a response header inside core's reserved surface +/// (see +/// [`reserved_response_effect`](crate::ec::module::reserved_response_effect)). +/// 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 module'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)); + } + + // The module this request resolved when its context was read, so the + // endpoint answers with the instance the page's own request selected. The + // client value is verified from the posted body below, not from request info. + let Some(module) = ec_context.selected_module() else { + log::info!("EC resolve skipped: no Edge Cookie module 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 = module.resolve_from_client(&input)?; + log::debug!( + "EC resolve handled (module={}): id {}", + module.id(), + if generated.id.is_some() { + "created" + } else { + "not created" + }, + ); + + // 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-` + // namespace, or a framing or hop-by-hop header. Without this a + // browser-side module could set `ts-ec` 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) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Module `{}` returned a response header `{name}` that {effect}", + module.id(), + ), + })); + } + } + + let generated_id = generated + .id + .map(|value| super::module::apply_module_code(module.as_ref(), &value)); + let Some(ec_id) = generated_id else { + let mut response = status_only(StatusCode::NO_CONTENT); + apply_module_response_headers(response.headers_mut(), generated.response_headers); + return Ok(response); + }; + + // The same identifier bounds as the organic generation path: reject, never + // rewrite. A module that created 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: module `{}` created an identifier outside the bounds", + module.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 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 (module={})", + module.id(), + ); + return Ok(status_only(StatusCode::CONFLICT)); + } + + // Persist the identity-graph row before setting the cookie, keyed by the + // module's canonical form, exactly like the organic generation path. + // Without a row, withdrawal could never reach this identity, so with no + // graph available this endpoint creates no cookie at all, which is stricter + // than the organic generation path, where the identifier is committed and + // only the row write is skipped. + let Some(graph) = kv else { + log::warn!("EC resolve skipped: no identity graph available, so no cookie is created"); + 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::module::module_kv_key(module.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 module asked for (for example to request + // more client evidence on a later request). Empty for the demo module. + // They accumulate with what this handler already set rather than replacing + // it, for the reasons on `module::apply_module_response_headers`; here + // that keeps the `Cache-Control: no-store` every identity response must + // 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); + // 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` is one this deployment authorizes to set +/// identity. +/// +/// The comparison is the same-origin test of RFC 6454 §5, so two origins +/// match only when their scheme, host and port triples (RFC 6454 §4) are +/// equal, with a missing port standing for the scheme's default. See +/// [`origins_match`]. `https://{publisher.domain}` is always accepted and +/// `[ec] resolve_allowed_origins` adds further origins. A subdomain of the publisher is not accepted unless it is listed, +/// because the Edge Cookie is scoped to the parent domain, so a delegated or +/// compromised sibling host would otherwise be able to fix an identity that +/// lands on the apex and every sibling with it. +/// +/// This is defense in depth rather than the primary control. The primary +/// control is the module's own verification of the value it is handed. +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 origin = origin.trim(); + + let default_origin = format!("https://{}", settings.publisher.domain); + if origins_match(origin, &default_origin) { + return true; + } + settings + .ec + .resolve_allowed_origins + .iter() + .any(|allowed| origins_match(origin, allowed)) +} + +/// Whether two serialized origins are the same origin under RFC 6454. +/// +/// Each side is read by [`parse_serialized_origin`], so a value that is not a +/// serialized origin, such as one carrying a path, a query or a fragment, +/// never matches. The two are then compared as the scheme, host and port +/// triple of RFC 6454 §4, which is the same-origin test of §5. The scheme and +/// host are case-insensitive and a missing port stands for the scheme's +/// default, so a configured `https://www.example.com:443` matches the +/// `https://www.example.com` a browser sends, while `http://` or another port +/// never matches. The opaque `null` origin (RFC 6454 §6.2) is never the same +/// as anything. +fn origins_match(candidate: &str, allowed: &str) -> bool { + match ( + parse_serialized_origin(candidate), + parse_serialized_origin(allowed), + ) { + (Ok(candidate), Ok(allowed)) => candidate == allowed, + _ => false, + } +} + +/// Why a value is not a serialized origin. +/// +/// Each reason reads as the end of a sentence about the value, so a message +/// can name the value and then say what is wrong with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum NotAnOrigin { + /// The value is empty. + #[display("is empty")] + Empty, + /// The value has no scheme, another scheme, or is the opaque `null`. + #[display("does not begin with `http://` or `https://`")] + Scheme, + /// A user name or password comes before the host. + #[display("carries a user name or password")] + Userinfo, + /// Something follows the host and port, a lone trailing slash included. + #[display("has a path, and a trailing slash is one")] + Path, + /// A query follows the host and port. + #[display("has a query")] + Query, + /// A fragment follows the host and port. + #[display("has a fragment")] + Fragment, + /// What follows the scheme is not a host with an optional port. + #[display("does not name a host with an optional port")] + Host, +} + +impl core::error::Error for NotAnOrigin {} + +/// Parses a serialized origin (RFC 6454 §6.1) into its scheme, host and port +/// triple (RFC 6454 §4). +/// +/// A serialized origin is `http://` or `https://` followed by a host and an +/// optional port, and nothing else. The scheme and host are case-insensitive +/// and a missing port stands for the scheme's default. The resolve endpoint +/// reads both the request's `Origin` and each +/// `[ec] resolve_allowed_origins` entry with this parse, and the +/// settings refuse an entry it rejects, so an entry that loads is one a +/// request can match. +/// +/// # Errors +/// +/// Returns the [`NotAnOrigin`] reason for any other value. +pub(crate) fn parse_serialized_origin(value: &str) -> Result { + if value.is_empty() { + return Err(NotAnOrigin::Empty); + } + let authority = ["https://", "http://"] + .into_iter() + .find_map(|prefix| { + let (scheme, rest) = value.split_at_checked(prefix.len())?; + scheme.eq_ignore_ascii_case(prefix).then_some(rest) + }) + .ok_or(NotAnOrigin::Scheme)?; + // The host and port end at the first of these, and an origin has nothing + // after them. The URL parser reads a backslash as a slash in these schemes. + match authority + .chars() + .find(|c| matches!(c, '/' | '\\' | '?' | '#' | '@')) + { + Some('@') => return Err(NotAnOrigin::Userinfo), + Some('?') => return Err(NotAnOrigin::Query), + Some('#') => return Err(NotAnOrigin::Fragment), + Some(_) => return Err(NotAnOrigin::Path), + None => {} + } + // The URL parser drops surrounding spaces and any tab or newline, so they + // are refused here rather than silently read as a different value. + if authority + .chars() + .any(|c| c.is_whitespace() || c.is_control()) + { + return Err(NotAnOrigin::Host); + } + let url = url::Url::parse(value).map_err(|_| NotAnOrigin::Host)?; + Ok(url.origin()) +} + +/// 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::constants::COOKIE_TS_EC_RESOLVED; + use crate::ec::finalize::ec_finalize_response; + use crate::ec::module::{ + CLIENT_FIXED_MODULE_KEY, EcModuleSelection, EdgeCookieModule, GeneratedEdgeCookie, + IdentityInput, + }; + use crate::ec::registry::PartnerRegistry; + use crate::evidence::RequestInfo; + use crate::permission_signal::{PermissionSignalModule, SignalInput}; + use crate::permissions::{ConsentSignal, Permission}; + use crate::platform::test_support::{ + NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, noop_services, + noop_services_with_ec_module, + }; + use crate::platform::{ClientInfo, RuntimeServices}; + use crate::test_support::tests::create_test_settings; + use http::Method; + use std::collections::BTreeMap; + use std::sync::Arc; + + fn settings_with_client_fixed() -> Settings { + let mut settings = create_test_settings(); + settings.ec.module = Some(EcModuleSelection::from(CLIENT_FIXED_MODULE_KEY)); + settings + } + + // The fixed word shared by the `client_fixed` module 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") + } + + /// A context whose gate is set by hand, carrying the module the settings + /// select, resolved as a request's own context resolves it. + fn gated(settings: &Settings, ec_allowed: bool) -> EcContext { + with_selected_module( + settings, + EcContext::new_for_test_gated(None, ConsentContext::default(), ec_allowed), + ) + } + + fn with_selected_module(settings: &Settings, context: EcContext) -> EcContext { + match crate::ec::module::request_module(&settings.ec, &noop_services()) + .expect("should resolve the selected module") + { + Some(module) => context.with_module_for_test(module), + None => context, + } + } + + /// 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** module modeling a client-generated, first-party + /// identifier (a `UUID`) that the browser creates and posts back for the server + /// to set. It is not a production module 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 module + /// (like any opaque-identifier module) returns the value unchanged. + /// + /// [`normalize_id_for_kv`]: EdgeCookieModule::normalize_id_for_kv + #[derive(Debug)] + struct TestIdModule; + + impl EdgeCookieModule for TestIdModule { + fn id(&self) -> &'static str { + "testid" + } + + fn code(&self) -> crate::ec::module::ModuleCode { + crate::module_code!("t0id") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + // The identifier is created 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() + } + } + + /// The identifier [`ResolveHeaderModule`] creates when asked to. + const HEADER_MODULE_ID: &str = "5c3a1b70-2f4d-4a19-9c6e-7b0d18e4a221"; + + /// A **test-only** module that returns caller-chosen response headers + /// and identifier from the client-resolve path, so a test can drive one + /// module response effect at a time through this endpoint, with and + /// without an identifier. + #[derive(Debug)] + struct ResolveHeaderModule { + headers: &'static [(&'static str, &'static str)], + id: Option<&'static str>, + } + + impl EdgeCookieModule for ResolveHeaderModule { + fn id(&self) -> &'static str { + "resolve-header" + } + + fn code(&self) -> crate::ec::module::ModuleCode { + crate::module_code!("t0rh") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn resolve_from_client( + &self, + _input: &ClientResolveInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: self.id.map(str::to_owned), + response_headers: self + .headers + .iter() + .map(|(name, value)| { + ( + http::HeaderName::from_bytes(name.as_bytes()) + .expect("should parse header name"), + HeaderValue::from_static(value), + ) + }) + .collect(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + /// Drives one resolve request through [`ResolveHeaderModule`], returning + /// whatever the handler produced. + fn resolve_with_header_module( + headers: &'static [(&'static str, &'static str)], + id: Option<&'static str>, + graph: Option<&crate::ec::kv::KvIdentityGraph>, + ) -> Result, Report> { + resolve_request_with_header_module(headers, id, post(HEADER_MODULE_ID), graph) + } + + /// Drives `request` through [`ResolveHeaderModule`], threaded the way a + /// composition root threads a module. + fn resolve_request_with_header_module( + headers: &'static [(&'static str, &'static str)], + id: Option<&'static str>, + request: Request, + graph: Option<&crate::ec::kv::KvIdentityGraph>, + ) -> Result, Report> { + let mut settings = create_test_settings(); + settings.ec.module = Some(EcModuleSelection::from("resolve-header")); + let services = noop_services_with_ec_module(Arc::new(ResolveHeaderModule { headers, id })); + let organic = Request::builder() + .method(Method::GET) + .uri("https://edge.example.com/") + .body(EdgeBody::empty()) + .expect("should build organic request"); + let ec = EcContext::read_from_request(&settings, &organic, &services) + .expect("should read EC context"); + handle_ec_resolve(&settings, request, &ec, graph) + } + + #[test] + fn resolve_rejects_a_reserved_response_effect_when_nothing_is_created() { + // The 204 path applies module headers too, so without the check a + // browser-side module could set the managed identity cookie while + // returning no identifier, walking past the identifier bounds, the + // conflict check and the row-before-cookie rule. + let outcome = + resolve_with_header_module(&[("set-cookie", "ts-ec=forged-value; Path=/")], None, None); + + let err = outcome.expect_err("a managed cookie effect should fail the request"); + assert!( + format!("{err:?}").contains("ts-` namespace"), + "the failure should name the reserved effect, got {err:?}" + ); + } + + #[test] + fn resolve_rejects_a_reserved_response_effect_on_the_created_path() { + // The same check has to cover the 200 path, where the module does + // create and core is about to write its own cookie and headers. + let graph = in_memory_graph(); + let outcome = resolve_with_header_module( + &[("x-ts-ec", "forged-value")], + Some(HEADER_MODULE_ID), + Some(&graph), + ); + + let err = outcome.expect_err("a reserved header effect should fail the request"); + assert!( + format!("{err:?}").contains("x-ts-` namespace"), + "the failure should name the reserved effect, got {err:?}" + ); + } + + #[test] + fn resolve_accumulates_module_response_headers_with_its_own() { + // The module's own cookies must add to what the handler already set, + // never replace it. Replacing would keep only the last of a module's + // own cookies, and would drop the `Cache-Control: no-store` core writes + // onto every identity response (a module cannot set `cache-control` + // itself, so core's directive is what has to survive). + let graph = in_memory_graph(); + let response = resolve_with_header_module( + &[ + ("set-cookie", "vendor-ev=abc; Path=/"), + ("set-cookie", "vendor-state=xyz; Path=/"), + ], + Some(HEADER_MODULE_ID), + Some(&graph), + ) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::OK, + "a created identifier should return 200" + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + for expected in ["vendor-ev=abc", "vendor-state=xyz", "ts-ec=", "ts-ecr=1"] { + assert!( + cookies.iter().any(|cookie| cookie.starts_with(expected)), + "`{expected}` should survive on the response, got {cookies:?}" + ); + } + + let cache_control: Vec<&str> = response + .headers() + .get_all(header::CACHE_CONTROL) + .iter() + .map(|value| { + value + .to_str() + .expect("should render cache-control as utf-8") + }) + .collect(); + assert!( + cache_control.contains(&"no-store"), + "a module header must not drop the no-store an identity response carries, got {cache_control:?}" + ); + } + + #[test] + fn resolve_rejects_an_identifier_outside_the_cookie_bounds() { + // An identifier the cookie cannot carry is refused, never rewritten, + // and leaves neither a cookie nor an identity-graph row behind. + let graph = in_memory_graph(); + let response = resolve_with_header_module(&[], Some("has a space"), Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "an identifier outside the cookie-safe bounds should be refused with 400" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a refused identifier must set no cookie" + ); + assert!( + graph + .get("t0rh~has a space") + .expect("should read the graph") + .is_none(), + "a refused identifier must leave no identity-graph row" + ); + } + + #[test] + fn resolve_answers_503_when_the_identity_graph_write_fails() { + // The row is written before the cookie, so when the write fails no + // cookie is set, because withdrawal could not reach an identity with + // no row. + let graph = crate::ec::kv::KvIdentityGraph::failing("test-ec-store"); + let response = resolve_with_header_module(&[], Some(HEADER_MODULE_ID), Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "a failed identity-graph write should answer 503" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a failed identity-graph write must set no cookie" + ); + } + + #[test] + fn resolve_refuses_an_advertised_oversized_body_before_reading_it() { + // A `Content-Length` over the limit is refused before the body is read, + // whatever the body turns out to hold. + let mut request = post(HEADER_MODULE_ID); + request + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from(MAX_BODY_SIZE + 1)); + let graph = in_memory_graph(); + let response = + resolve_request_with_header_module(&[], Some(HEADER_MODULE_ID), request, Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "an advertised length over the limit should be refused with 413" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a refused body must set no cookie" + ); + } + + #[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 module: Arc = Arc::new(TestIdModule); + let mut settings = create_test_settings(); + settings.ec.module = Some(EcModuleSelection::from("testid")); + let services = noop_services_with_ec_module(Arc::clone(&module)); + + // 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 module defers creation 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, and the identifier reads + // back verbatim because the module's own `accepts_id` decides its + // shape. + 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(&settings, 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" + ); + } + + /// A resolve that creates nothing answers 204 and sets no cookie, whatever + /// stopped it. + #[test] + fn resolve_answers_204_with_no_cookie_when_it_creates_nothing() { + let mut no_module = create_test_settings(); + no_module.ec.module = None; + for (case, settings, body, allowed, has_graph) in [ + ( + "the permission gate is closed", + settings_with_client_fixed(), + FIXED_WORD, + false, + true, + ), + ("no module is configured", no_module, "123", true, true), + ( + "there is no identity graph to hold the row, so a cookie would be a \ + phantom identity", + settings_with_client_fixed(), + FIXED_WORD, + true, + false, + ), + ( + "the posted value fails verification", + settings_with_client_fixed(), + "not-the-word", + true, + true, + ), + ] { + let graph = in_memory_graph(); + let response = handle_ec_resolve( + &settings, + post(body), + &gated(&settings, allowed), + has_graph.then_some(&graph), + ) + .expect("should handle resolve"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT, "{case}"); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "{case}: should set no cookie" + ); + } + } + + /// A context read the way a composition root builds one, with the selected + /// built-in module threaded through the services, at a location whose + /// rules set storage without a signal so the gate is open. + fn resolve_with_threaded_module(settings: &Settings, body: &str) -> Response { + let module = crate::ec::module::build_reusable_module(&settings.ec, None, None) + .expect("should build the selected module") + .expect("a built-in module is reusable"); + let services = noop_services_with_ec_module(module); + let organic = Request::builder() + .method(Method::GET) + .uri("https://test-publisher.com/") + .body(EdgeBody::empty()) + .expect("should build organic request"); + let geo = crate::platform::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + }; + let ec = EcContext::read_from_request_with_geo(settings, &organic, &services, Some(&geo)) + .expect("should read EC context"); + assert!( + ec.ec_allowed(), + "the gate must be open for this to test anything" + ); + + handle_ec_resolve(settings, post(body), &ec, Some(&in_memory_graph())) + .expect("a valid selection should not be an error") + } + + #[test] + fn resolve_answers_204_for_a_threaded_module_that_creates_nothing() { + let response = resolve_with_threaded_module(&create_test_settings(), FIXED_WORD); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "a module that creates nothing from a client post should answer 204" + ); + } + + #[test] + fn resolve_sets_the_cookie_for_a_threaded_client_fixed_module() { + let response = resolve_with_threaded_module(&settings_with_client_fixed(), FIXED_WORD); + + assert_eq!( + response.status(), + StatusCode::OK, + "the fixed word should create the Edge Cookie" + ); + assert!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .any(|value| value + .to_str() + .is_ok_and(|cookie| cookie.starts_with("ts-ec="))), + "should set the Edge Cookie" + ); + } + + /// A request the endpoint cannot take is refused, and sets no cookie. + #[test] + fn resolve_refuses_a_request_it_cannot_take() { + let settings = settings_with_client_fixed(); + let oversized = "x".repeat(MAX_BODY_SIZE + 1); + let own = Some("https://test-publisher.com"); + let text = Some("text/plain"); + for (case, origin, content_type, body, status) in [ + ( + "no Origin, because identity is set only from the publisher's own site", + None, + text, + FIXED_WORD, + StatusCode::FORBIDDEN, + ), + ( + "a foreign origin", + Some("https://attacker.example"), + text, + FIXED_WORD, + StatusCode::FORBIDDEN, + ), + ( + "a sibling subdomain, which would set identity that lands on the apex", + Some("https://www.test-publisher.com"), + text, + FIXED_WORD, + StatusCode::FORBIDDEN, + ), + ( + "plain http, because the scheme is part of the origin", + Some("http://test-publisher.com"), + text, + FIXED_WORD, + StatusCode::FORBIDDEN, + ), + ( + "a port, because the port is part of the origin", + Some("https://test-publisher.com:8443"), + text, + FIXED_WORD, + StatusCode::FORBIDDEN, + ), + ( + "a form body, because only text and JSON are resolve payloads", + own, + Some("application/x-www-form-urlencoded"), + FIXED_WORD, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ), + ( + "a body over the limit", + own, + text, + oversized.as_str(), + StatusCode::PAYLOAD_TOO_LARGE, + ), + ] { + let graph = in_memory_graph(); + let response = handle_ec_resolve( + &settings, + post_with(origin, content_type, body), + &gated(&settings, true), + Some(&graph), + ) + .expect("should handle resolve"); + + assert_eq!(response.status(), status, "{case}"); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "{case}: should set no cookie" + ); + } + } + + #[test] + fn resolve_accepts_a_configured_extra_origin() { + let mut settings = settings_with_client_fixed(); + settings + .ec + .resolve_allowed_origins + .push("https://www.test-publisher.com".to_owned()); + 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(&settings, true), Some(&graph)) + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::OK, + "an operator should be able to authorize the origin their pages are served from" + ); + } + + #[test] + fn origins_match_follows_rfc_6454() { + // Same triple, so the same origin (RFC 6454 §5), however it is written. + for (candidate, allowed) in [ + ("https://www.example.com", "https://www.example.com:443"), + ("https://www.example.com", "HTTPS://WWW.EXAMPLE.COM"), + ("http://www.example.com", "http://www.example.com:80"), + ( + "https://www.example.com:8443", + "https://www.example.com:8443", + ), + ] { + assert!( + origins_match(candidate, allowed), + "{candidate} and {allowed} should be the same origin" + ); + } + // A different scheme, host or port is a different origin, and a value + // that is not a serialized origin (RFC 6454 §6.1) never matches. + for (candidate, allowed) in [ + ("http://www.example.com", "https://www.example.com"), + ("https://www.example.com:8443", "https://www.example.com"), + ("https://sub.www.example.com", "https://www.example.com"), + ("https://www.example.com/", "https://www.example.com"), + ("https://www.example.com/path", "https://www.example.com"), + ("https://www.example.com?x=1", "https://www.example.com"), + ("null", "https://www.example.com"), + ("null", "null"), + ("www.example.com", "www.example.com"), + ] { + assert!( + !origins_match(candidate, allowed), + "{candidate} and {allowed} should not be the same origin" + ); + } + } + + #[test] + fn origins_match_refuses_what_the_settings_refuse() { + // The URL parser reads each of these as a tuple origin, but none is a + // serialized origin, so none matches even itself. + for value in [ + "https://user@www.example.com", + "https://@www.example.com", + "ftp://www.example.com", + "wss://www.example.com", + "https://www.example.com/.", + "https://www.example.com\\", + "https:www.example.com", + ] { + assert!( + !origins_match(value, value), + "{value} is not a serialized origin, so it should match nothing" + ); + } + } + + #[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 = with_selected_module( + &settings, + 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" + ); + } + + /// A visitor's consent record, standing in for a signal module such as + /// the TCF one, which core does not link. `Grant` sets storage and + /// `Withdraw` refuses and withdraws it. + #[derive(Clone, Copy)] + enum StorageRecord { + Grant, + Withdraw, + } + + impl PermissionSignalModule for StorageRecord { + fn id(&self) -> &'static str { + "storage-record" + } + + fn signal(&self, permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + if permission != Permission::StoreOnDevice { + return ConsentSignal::Neutral; + } + match self { + Self::Grant => ConsentSignal::Grant, + Self::Withdraw => ConsentSignal::Revoke, + } + } + + fn withdraws(&self, permission: Permission, _input: &SignalInput<'_>) -> bool { + permission == Permission::StoreOnDevice && matches!(self, Self::Withdraw) + } + } + + /// Services for a visitor whose record answers `record`, so the gate and + /// any withdrawal come from permission assembly, as in a deployment. No + /// location resolves, so the policy's top node applies, and storage there + /// needs a signal. + fn services_with_record(record: StorageRecord) -> RuntimeServices { + let modules: Vec> = vec![Arc::new(record)]; + crate::platform::test_support::test_services_builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo::default()) + .permission_signal_modules(modules.into()) + .build() + } + + /// The publisher cookies a browser holds, by name. + type Jar = BTreeMap; + + /// Stores a response's `Set-Cookie` headers in `jar` in order, as a + /// browser does, where `Max-Age=0` deletes the cookie. + fn store_cookies(jar: &mut Jar, response: &Response) { + for value in response.headers().get_all(header::SET_COOKIE) { + let line = value.to_str().expect("should render set-cookie as utf-8"); + let (name, value) = line + .split(';') + .next() + .and_then(|pair| pair.split_once('=')) + .expect("should start with a cookie name and value"); + if line.contains("Max-Age=0") { + jar.remove(name); + } else { + jar.insert(name.to_owned(), value.to_owned()); + } + } + } + + /// Sends the cookies in `jar` with `req`, as a browser does. + fn send_cookies(req: &mut Request, jar: &Jar) { + if jar.is_empty() { + return; + } + let cookies = jar + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("; "); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&cookies).expect("should be a valid cookie header"), + ); + } + + /// One page view as the publisher path handles it: the context is read, + /// organic generation runs, and the response is finalized before the + /// browser stores what it sets. + fn view_page( + settings: &Settings, + services: &RuntimeServices, + graph: &KvIdentityGraph, + jar: &mut Jar, + ) -> EcContext { + let mut req = Request::builder() + .method(Method::GET) + .uri("https://test-publisher.com/") + .body(EdgeBody::empty()) + .expect("should build page request"); + send_cookies(&mut req, jar); + let mut ec = + EcContext::read_from_request(settings, &req, services).expect("should read EC context"); + ec.generate_if_needed(settings, Some(graph)) + .expect("should run organic generation"); + let mut response = Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::empty()) + .expect("should build page response"); + ec_finalize_response( + settings, + &mut ec, + Some(graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + store_cookies(jar, &response); + ec + } + + /// The demo page script's post as the adapter handles it: the context is + /// read from the post, the handler answers, and the response is + /// finalized before the browser stores what it sets. + fn post_fixed_word( + settings: &Settings, + services: &RuntimeServices, + graph: &KvIdentityGraph, + jar: &mut Jar, + ) -> StatusCode { + let mut req = post(FIXED_WORD); + send_cookies(&mut req, jar); + let mut ec = + EcContext::read_from_request(settings, &req, services).expect("should read EC context"); + let mut response = + handle_ec_resolve(settings, req, &ec, Some(graph)).expect("should handle resolve"); + ec_finalize_response( + settings, + &mut ec, + Some(graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + store_cookies(jar, &response); + response.status() + } + + #[test] + fn the_demo_identity_is_recognized_and_keeps_its_marker() { + let settings = settings_with_client_fixed(); + let services = services_with_record(StorageRecord::Grant); + let graph = in_memory_graph(); + let mut jar = Jar::new(); + + assert_eq!( + post_fixed_word(&settings, &services, &graph, &mut jar), + StatusCode::OK, + "the first post should create the identity" + ); + assert_eq!( + jar.get(COOKIE_TS_EC_RESOLVED).map(String::as_str), + Some("1"), + "the resolve should set the marker" + ); + + let ec = view_page(&settings, &services, &graph, &mut jar); + + assert_eq!( + ec.ec_value(), + Some("cfix~an-ec"), + "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), + Some("1"), + "the next view should keep the marker once it is set, got {jar:?}" + ); + } + + #[test] + fn the_demo_page_does_not_post_again_on_the_next_view() { + let settings = settings_with_client_fixed(); + let services = services_with_record(StorageRecord::Grant); + let graph = in_memory_graph(); + let mut jar = Jar::new(); + let mut posts = 0; + + for _view in 0..3 { + view_page(&settings, &services, &graph, &mut jar); + // 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) { + posts += 1; + post_fixed_word(&settings, &services, &graph, &mut jar); + } + } + + assert_eq!(posts, 1, "the page should post on the first view only"); + } + + #[test] + fn a_withdrawal_tombstones_the_demo_identity() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let mut jar = Jar::new(); + let granted = services_with_record(StorageRecord::Grant); + assert_eq!( + post_fixed_word(&settings, &granted, &graph, &mut jar), + StatusCode::OK, + "the first post should create the identity" + ); + + let withdrawn = services_with_record(StorageRecord::Withdraw); + let ec = view_page(&settings, &withdrawn, &graph, &mut jar); + + assert!( + ec.storage_withdrawn(), + "the record should withdraw storage for this to test anything" + ); + let (row, _) = graph + .get("cfix~an-ec") + .expect("should read the graph") + .expect("the row should remain as a tombstone"); + assert!( + !row.consent.ok, + "a withdrawal should tombstone the demo identity's row" + ); + assert!( + jar.is_empty(), + "a withdrawal should expire the Edge Cookie and the marker, got {jar:?}" + ); + } +} diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index e298a9004d..cfd042fab8 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -754,6 +754,10 @@ struct IntegrationRegistryInner { html_stream_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 module. Populated in [`IntegrationRegistry::new`] from settings. + extra_js_module_ids: Vec<&'static str>, } impl Default for IntegrationRegistryInner { @@ -775,6 +779,7 @@ impl Default for IntegrationRegistryInner { html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), + extra_js_module_ids: Vec::new(), } } } @@ -940,6 +945,20 @@ impl IntegrationRegistry { } } + // A client-cycle Edge Cookie module ships a page script that posts its + // result to the resolve endpoint. The script rides the tsjs bundle, so + // include its module when that module is selected. The same module + // list drives both the served bundle and the injected `