diff --git a/.cargo/config.toml b/.cargo/config.toml index 1302091e0..bc8c20d71 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # --- Axum adapter (native dev server) --- build-axum = "build -p trusted-server-adapter-axum" diff --git a/Cargo.lock b/Cargo.lock index 311597aae..1a89ee607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5400,6 +5400,8 @@ dependencies = [ "serde_json", "toml", "trusted-server-core", + "trusted-server-device-fastly", + "trusted-server-geo-fastly", "url", "urlencoding", ] @@ -5515,6 +5517,23 @@ dependencies = [ "web-time", ] +[[package]] +name = "trusted-server-device-fastly" +version = "0.1.0" +dependencies = [ + "fastly", + "trusted-server-core", +] + +[[package]] +name = "trusted-server-geo-fastly" +version = "0.1.0" +dependencies = [ + "error-stack", + "fastly", + "trusted-server-core", +] + [[package]] name = "trusted-server-integration-tests" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index dfd94d0c4..9e3ef176d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "2" members = [ + "crates/device/fastly", + "crates/geo/fastly", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -110,6 +112,8 @@ toml = "1.1" toml_edit = "0.23.10" tower = "0.4" trusted-server-core = { path = "crates/trusted-server-core" } +trusted-server-device-fastly = { path = "crates/device/fastly" } +trusted-server-geo-fastly = { path = "crates/geo/fastly" } trusted-server-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/crates/device/README.md b/crates/device/README.md new file mode 100644 index 000000000..3d2ca5f9f --- /dev/null +++ b/crates/device/README.md @@ -0,0 +1,10 @@ +# Device providers + +Device-detection provider crates live here, one per vendor. The Fastly provider +(`trusted-server-device-fastly`) classifies a request with the host's TLS and +HTTP/2 signals. Future vendor providers (for example +`crates/device/`) slot in alongside it. + +The built-in default provider (User-Agent only) ships in `trusted-server-core` +(`ec::device`). Adapters select and inject the vendor provider via +`build_device_provider`. diff --git a/crates/device/fastly/Cargo.toml b/crates/device/fastly/Cargo.toml new file mode 100644 index 000000000..8d4b61d48 --- /dev/null +++ b/crates/device/fastly/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "trusted-server-device-fastly" +description = "Fastly host device provider exposing opt-in TLS and HTTP/2 signals." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +fastly = { workspace = true } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. Fastly has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs new file mode 100644 index 000000000..9b36ea369 --- /dev/null +++ b/crates/device/fastly/src/lib.rs @@ -0,0 +1,98 @@ +//! The Fastly device provider and host-signal capture. +//! +//! [`FastlyDeviceProvider`] strengthens the built-in User-Agent classification +//! with the host's TLS (JA4) and HTTP/2 signals, for deployments on Fastly +//! Compute. It is selected by `[device] provider = "fastly"` and wired in by the +//! Fastly adapter, which injects the request info and the captured host signals. +//! +//! [`FastlyHostSignals`] captures those signals from a live Fastly request +//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`) into owned values, so it can +//! be shared as an injected [`HostSignals`] service that outlives the borrow of +//! the request. Capturing through the SDK is why this crate depends on the +//! `fastly` crate and builds only for the `wasm32-wasip1` target; off-host the +//! accessors return `None`, so classification degrades to User-Agent only. The +//! platform-neutral [`HostSignals`], [`RequestInfo`], and [`DeviceProvider`] +//! traits and the built-in default live in `trusted-server-core`, where the +//! `DeviceSignals` classification logic stays unit-tested. + +use std::sync::Arc; + +use fastly::Request as FastlyRequest; +use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals}; +use trusted_server_core::evidence::{HostSignals, RequestInfo}; + +/// Host-computed client signals captured from a live Fastly request. +/// +/// Reads the TLS JA4 and HTTP/2 signals once through the Fastly SDK and +/// owns them, so the value can be injected as a [`HostSignals`] service that +/// outlives the borrow of the request it was captured from. Off-host the SDK +/// accessors return `None`, so the signals are simply absent. +#[derive(Debug, Clone, Default)] +pub struct FastlyHostSignals { + ja4: Option, + h2: Option, +} + +impl FastlyHostSignals { + /// Builds host signals from already-captured signal values. + /// + /// Use this when the adapter has read the signals once (for example + /// into the client metadata, or from the trusted internal headers the entry + /// point injects) and wants to share them without another SDK call. + #[must_use] + pub fn new(ja4: Option, h2: Option) -> Self { + Self { ja4, h2 } + } + + /// Captures the TLS JA4 and HTTP/2 signals from a live Fastly request. + #[must_use] + pub fn from_request(req: &FastlyRequest) -> Self { + Self { + ja4: req.get_tls_ja4().map(str::to_string), + h2: req.get_client_h2_fingerprint().map(str::to_string), + } + } +} + +impl HostSignals for FastlyHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } +} + +/// The Fastly device provider, opt-in via `[device] provider = "fastly"`. +/// +/// Classifies a request with [`DeviceSignals::derive`], which strengthens the +/// User-Agent classification with the host signals. It reads the User-Agent +/// from its injected [`RequestInfo`] and the TLS and HTTP/2 signals from its +/// injected [`HostSignals`], so the browser/bot gate is backed by the live +/// request. +pub struct FastlyDeviceProvider { + host_signals: Arc, +} + +impl FastlyDeviceProvider { + /// Creates the provider with its injected host signals. + #[must_use] + pub fn new(host_signals: Arc) -> Self { + Self { host_signals } + } +} + +impl DeviceProvider for FastlyDeviceProvider { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive( + request_info.user_agent(), + self.host_signals.ja4(), + self.host_signals.h2(), + ) + } +} diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..0c8d6be59 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,18 @@ +# Edge Cookie providers + +Vendor Edge Cookie provider crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieProvider` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in HMAC provider (HMAC over the client IP) ships in +`trusted-server-core` (`ec::provider`), so no crate is needed for it. There is +no default provider, and a deployment selects one explicitly with +`[ec] provider`. + +A provider's own settings live in the `[ec.]` table the selector names. +The name is the provider's implementation id, the same string its +`EdgeCookieProvider::id` returns, unless the table names one with +`implementation = ""`, which lets an operator configure a provider under a +name of their own choosing. A provider with no settings needs no table. + +This directory is a placeholder until a vendor provider is added. diff --git a/crates/fastly.toml b/crates/fastly.toml new file mode 100644 index 000000000..718e87ad3 --- /dev/null +++ b/crates/fastly.toml @@ -0,0 +1,13 @@ +# Minimal Viceroy config for testing crates nested one level deeper than the +# adapters (for example `crates/device/fastly` and `crates/geo/fastly`). +# +# The shared wasm test runner in `.cargo/config.toml` starts Viceroy with +# `-C ../../fastly.toml`, resolved from the crate directory. For a two-level +# crate such as `crates/trusted-server-adapter-fastly` that reaches the +# repository root manifest. For a three-level crate it resolves here, to +# `crates/fastly.toml`. These crates' unit tests use no backends, KV stores, +# or dictionaries, only a manifest Viceroy can start from. +manifest_version = 3 +name = "trusted-server-nested-crate-tests" + +[local_server] diff --git a/crates/geo/README.md b/crates/geo/README.md new file mode 100644 index 000000000..f3c5d11fb --- /dev/null +++ b/crates/geo/README.md @@ -0,0 +1,20 @@ +# Geo providers + +Geo and IP-intelligence provider crates live here, one per implementation, each +implementing the `PlatformGeo` trait from `trusted-server-core`: + +- `crates/geo/fastly` (`trusted-server-geo-fastly`) is the host platform geo + provider for Fastly Compute, wrapping Fastly's `geo_lookup`. The Fastly adapter + injects it via `build_geo_provider`. It depends on the Fastly SDK, so it builds + only for `wasm32-wasip1`. +- Vendor geo providers (for example `crates/geo/`) will live alongside + it, one per vendor, selected by the `[geo] provider` setting. + +Whatever the source, a provider returns the same `GeoInfo` coding. The country +is an ISO 3166-1 alpha-2 code (`US`) and the region is the ISO 3166-2 subdivision +code with no country prefix (`CA`), so the Fastly and other providers feed the +same downstream rules without translation. + +The platform-neutral `PlatformGeo` trait and the `DisabledGeo` default (no +location) both live in `trusted-server-core`, so the default deployment resolves +no location until a provider is selected. diff --git a/crates/geo/fastly/Cargo.toml b/crates/geo/fastly/Cargo.toml new file mode 100644 index 000000000..79d3acf62 --- /dev/null +++ b/crates/geo/fastly/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "trusted-server-geo-fastly" +description = "Fastly host geo provider backed by the Fastly geolocation API." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +error-stack = { workspace = true } +fastly = { workspace = true } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. Fastly has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/geo/fastly/src/lib.rs b/crates/geo/fastly/src/lib.rs new file mode 100644 index 000000000..5128fa7c6 --- /dev/null +++ b/crates/geo/fastly/src/lib.rs @@ -0,0 +1,47 @@ +//! The Fastly host geo provider. +//! +//! [`FastlyPlatformGeo`] implements [`PlatformGeo`] using Fastly's `geo_lookup`, +//! for deployments on Fastly Compute. It is the host platform's geo provider, +//! injected by the Fastly adapter via `build_geo_provider`. With no selector, +//! or `provider = "platform"`, this host lookup resolves the location, and +//! `provider = "none"` disables geo instead. +//! +//! Like the Fastly device provider, this crate calls the Fastly SDK directly, +//! so it depends on the `fastly` crate and builds only for the `wasm32-wasip1` +//! target. The platform-neutral `PlatformGeo` trait and the `DisabledGeo` +//! default both live in `trusted-server-core`. + +use std::net::IpAddr; + +use error_stack::Report; +use fastly::geo::{Geo, geo_lookup}; +use trusted_server_core::platform::{GeoInfo, PlatformError, PlatformGeo}; + +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. +fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + asn: None, + } +} + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// +/// The host platform geo provider for Fastly Compute. The adapter injects it via +/// `build_geo_provider`. With no selector, or `provider = "platform"`, it +/// resolves the location, and `provider = "none"` disables geo instead. +pub struct FastlyPlatformGeo; + +impl PlatformGeo for FastlyPlatformGeo { + fn lookup(&self, client_ip: Option) -> Result, Report> { + Ok(client_ip + .and_then(geo_lookup) + .map(|geo| geo_from_fastly(&geo))) + } +} diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 38776eb95..88cefe4ab 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -18,6 +18,7 @@ use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -76,11 +77,26 @@ fn build_state() -> Result, Report> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. The Axum dev server injects + // no Edge Cookie provider into `RuntimeServices`, so `None` is exactly what + // `EcContext` sees per request; pass the injected provider here as well + // once this adapter supplies one. + // + // This adapter checks rather than keeps what the check resolved, unlike the + // Fastly, Cloudflare and Spin adapters, because it is a long-lived process + // whose application state is built once at start-up while theirs is rebuilt + // for every request. It injects and threads no provider, so `EcContext` + // resolves the selection itself on every request, building a fresh built-in + // provider that reads no request data. It supplies no host signals either, + // so the `host_signals` argument is `None`. + ensure_provider_available(&settings.ec, None, None)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -142,7 +158,7 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &state.settings); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, @@ -162,13 +178,26 @@ where /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent). A -/// malformed consent string is logged and falls back to the default -/// (fail-closed) context rather than being silently swallowed. -fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { +/// Geo comes from the platform (a no-op on the local Axum dev server, so +/// jurisdiction stays Unknown there unless the request carries TCF consent), and +/// a geo lookup failure is logged and treated as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -177,10 +206,6 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) None }); EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } // --------------------------------------------------------------------------- @@ -227,7 +252,7 @@ async fn dispatch_fallback( // Run the server-side auction with the configured creative-opportunity // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); + let mut ec_context = build_ec_context(state, services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -463,7 +488,7 @@ fn named_route_handler( // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let mut ec_context = build_ec_context(&state, &services, &req); + let mut ec_context = build_ec_context(&state, &services, &req)?; handle_auction( &state.settings, &state.orchestrator, @@ -482,7 +507,7 @@ fn named_route_handler( if req.method() == Method::OPTIONS { Ok(page_bids_preflight_denied()) } else { - let mut ec_context = build_ec_context(&state, &services, &req); + let mut ec_context = build_ec_context(&state, &services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -649,3 +674,79 @@ fn build_router(state: &Arc) -> RouterService { router.build() } + +#[cfg(test)] +mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + + use super::*; + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// Builds application state directly, bypassing the composition root's + /// startup check, so the per-request behavior can be exercised with a + /// selection the adapter cannot supply. + fn state_with_uninjected_provider() -> AppState { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan")); + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings) + .expect("should build orchestrator"); + let registry = + IntegrationRegistry::with_plan(&settings, plan).expect("should build registry"); + AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + } + } + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let state = state_with_uninjected_provider(); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + let services = build_runtime_services(&ctx, &state.settings); + let req = ctx.into_request(); + + let error = build_ec_context(&state, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index fd11d7728..609d66a25 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -193,6 +193,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 7dcdd53d8..16a483f4c 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -525,7 +525,10 @@ impl PlatformHttpClient for AxumPlatformHttpClient { /// KV store is [`trusted_server_core::platform::UnavailableKvStore`] — any route /// touching synthetic-ID or consent KV will degrade gracefully. A `warn` log is /// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { log::warn!( @@ -570,9 +573,12 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // API-route integration flow by reusing a poisoned connection after a // truncated POST. Revisit pooling if profiling shows allocation cost. .http_client(Arc::new(AxumPlatformHttpClient::new())) - .geo(Arc::clone(GEO.get_or_init(|| { - Arc::new(AxumPlatformGeo) as Arc - }))) + // Route through the [geo] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 6812b7421..16ac3d54a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -33,6 +33,9 @@ fn test_settings() -> trusted_server_core::settings::Settings { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -867,3 +870,54 @@ async fn first_party_proxy_rebuild_is_routed() { "/first-party/proxy-rebuild must be routed" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "integration-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and the Axum dev +/// server injects no vendor Edge Cookie provider, so only the composition root +/// can catch it. Without the startup check the deployment would come up and +/// answer every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = trusted_server_adapter_axum::app::TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 7ea582ee8..8f5ffafbe 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -20,6 +20,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -72,6 +73,16 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. @@ -137,11 +148,20 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { + // Composition root: resolve the provider selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // supplies no host signals and injects no vendor Edge Cookie provider, so + // both arguments are `None`, and each is passed here once this adapter + // supplies it. + let ec_provider = build_reusable_provider(&settings.ec, None, None)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -151,6 +171,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -158,19 +179,37 @@ fn build_state_with_settings( // Per-request RuntimeServices // --------------------------------------------------------------------------- -fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx) +/// Builds the per-request services, carrying the Edge Cookie provider the +/// composition root already resolved so the request path does not resolve +/// `[ec] provider` a second time. Nothing is carried when the composition root +/// found nothing safe to keep, and the request path resolves for itself. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx, &state.settings) + .with_resolved_ec_provider(state.ec_provider.clone()) } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the Workers `cf` object when deployed. A malformed -/// consent string is logged and falls back to the default (fail-closed) context -/// rather than being silently swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +/// Geo comes from the Workers `cf` object when deployed, and a geo lookup +/// failure is logged and treated as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -179,10 +218,6 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } // --------------------------------------------------------------------------- @@ -209,7 +244,7 @@ where let s = Arc::clone(&state); let f = f.clone(); Box::pin(async move { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&s, &ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -407,7 +442,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); @@ -449,7 +484,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -554,7 +595,7 @@ fn build_router(state: &Arc) -> RouterService { // Build the geo-aware EC context so the auction consent gate // sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req)?; handle_auction( &s.settings, &s.orchestrator, @@ -618,7 +659,7 @@ fn build_router(state: &Arc) -> RouterService { // preflight fall through to a permissive origin would reopen exactly // the cross-site hole the canonical path closes. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req)?; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -659,8 +700,68 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. The settings are + /// parsed directly, bypassing the composition root's startup check, so the + /// per-request behavior can be exercised with a selection the adapter + /// cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // No resolved provider is threaded here, so the request path resolves + // the selection itself, which is what an embedder driving core + // directly does and where the loud failure has to stay. + let services = build_runtime_services(&ctx, &settings); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } + fn aps_profile_settings() -> Settings { let mut settings = Settings::from_toml( r#" diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 14efed56a..a9c5ba3d9 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -209,6 +209,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index cded42a0d..a25f1bd67 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -590,7 +590,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Geo information is read from Cloudflare's injected request headers /// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on /// the native host target simply produce empty/zero defaults. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] @@ -625,7 +628,9 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). - let geo = build_geo(ctx); + // Routed through the [geo] provider selector like the Fastly adapter, so + // the selector behaves the same on every adapter. + let geo = trusted_server_core::platform::build_geo_provider(settings, Arc::new(build_geo(ctx))); RuntimeServices::builder() .config_store(config_store) @@ -633,7 +638,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(geo)) + .geo(geo) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -650,15 +655,22 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R /// Reads Cloudflare geo headers injected by the Workers runtime. /// /// `cf-ipcountry` is available on all plans. `cf-ipcity`, `cf-ipcontinent`, -/// `cf-iplatitude`, and `cf-iplongitude` require an Enterprise plan. Absent or -/// unparseable values default to empty strings or `0.0`. Country code `XX` -/// (Cloudflare's "unknown" sentinel) is treated as absent. +/// `cf-iplatitude`, `cf-iplongitude` and `cf-region-code` require an Enterprise +/// plan and the visitor-location managed transform. Absent or unparseable +/// values default to empty strings or `0.0`. Country code `XX` (Cloudflare's +/// "unknown" sentinel) is treated as absent. +/// +/// The region is the ISO 3166-2 subdivision code from `cf-region-code`, for +/// example `CA`, and not the subdivision name from `cf-region`, because the US +/// state privacy rules in `[consent.us_states] privacy_states` are written as +/// two-letter codes and a name would never match one. struct CloudflareGeo { country: String, city: String, continent: String, latitude: f64, longitude: f64, + region: Option, } impl PlatformGeo for CloudflareGeo { @@ -673,7 +685,7 @@ impl PlatformGeo for CloudflareGeo { latitude: self.latitude, longitude: self.longitude, metro_code: 0, - region: None, + region: self.region.clone(), asn: None, })) } @@ -707,12 +719,19 @@ fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); + let region = headers + .get("cf-region-code") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); CloudflareGeo { country, city, continent, latitude, longitude, + region, } } @@ -789,6 +808,80 @@ mod tests { RequestContext::new(req, PathParams::default()) } + /// Builds a request context carrying multiple headers at once. + fn make_ctx_with_headers(headers: &[(&str, &str)]) -> RequestContext { + let mut builder = request_builder().method("GET").uri("https://example.com/"); + for (name, value) in headers { + builder = builder.header( + *name, + HeaderValue::from_str(value).expect("should parse test header value"), + ); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } + + #[test] + fn a_us_state_visitor_reaches_the_us_state_jurisdiction_and_its_opt_out() { + // This adapter used to hardcode `region: None` and read no region + // header, and the consequence ran all the way to the privacy outcome. + // `detect_jurisdiction` reaches its US branch only when the country is + // `US` and a region is present, so every US visitor fell through to + // `NonRegulated`, where `allows_ec_creation` returns true without ever + // reading `ctx.gpc`. A Sec-GPC opt-out was therefore ignored for every + // US visitor on Cloudflare. + let config = trusted_server_core::consent_config::ConsentConfig::default(); + + let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US"), ("cf-region-code", "CA")]); + let geo = build_geo(&ctx) + .lookup(None) + .expect("should look up without failing") + .expect("a country header should resolve a location"); + assert_eq!( + geo.region.as_deref(), + Some("CA"), + "the ISO 3166-2 subdivision code should reach the geo info" + ); + + let jurisdiction = + trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo), &config); + assert_eq!( + jurisdiction, + trusted_server_core::consent::jurisdiction::Jurisdiction::UsState("CA".to_owned()), + "a Californian visitor should reach the US state jurisdiction" + ); + + // Reaching that jurisdiction is what this adapter is responsible for, + // and it is the switch every downstream opt-out hangs off. A Sec-GPC + // signal, a GPP US sale opt-out and a US Privacy opt-out are all + // consulted on the US state branch and none of them are consulted on + // the unregulated one, so a visitor who never reaches the US state + // jurisdiction has every one of those signals ignored. Which gate reads + // the jurisdiction is core's business and changes across this stack, so + // it is core that tests the reading. + + // Without the region header nothing can place the visitor in a state, + // so the jurisdiction is the unregulated one and the same opt-out is + // ignored. That is the behavior this fix removes for any deployment + // whose plan supplies the header. + let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US")]); + let geo = build_geo(&ctx) + .lookup(None) + .expect("should look up without failing") + .expect("a country header should resolve a location"); + assert_eq!( + geo.region, None, + "no region header should mean no region, not an invented one" + ); + assert_eq!( + trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo), &config), + trusted_server_core::consent::jurisdiction::Jurisdiction::NonRegulated, + "with no region a US visitor cannot be placed in a privacy state" + ); + } + fn make_ctx_without_header() -> RequestContext { let req = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index fb498ce4e..5232b6677 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -36,6 +36,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -85,6 +88,9 @@ fn make_router() -> RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -675,3 +681,54 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { "tsjs catch-all handler must not return 5xx: got {status}" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and this adapter +/// injects no vendor Edge Cookie provider, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 65320faa6..115dac5e0 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -28,6 +28,8 @@ log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-device-fastly = { workspace = true } +trusted-server-geo-fastly = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 190be505c..5b8959a3e 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -117,6 +117,8 @@ use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; 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::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -125,8 +127,10 @@ use trusted_server_core::integrations::{ RequestFilterRegistryOutcome, }; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, + ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, 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, @@ -181,6 +185,16 @@ pub(crate) struct AppState { pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, pub(crate) auction_telemetry_sink: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + pub(crate) ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. @@ -207,11 +221,37 @@ pub(crate) fn load_settings_from_config_store( ) } +/// Build the application state from explicit settings. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. pub(crate) fn build_state_from_settings( settings: Settings, ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); + // Composition root: resolve the provider selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request, and keep what the resolution produced so the + // request path does not resolve the same settings again. This adapter + // injects no vendor Edge Cookie provider, so `None` is the injected + // argument, and one is passed here once this adapter supplies it. + // + // This adapter injects host signals on every request, so a startup instance + // with no captured signals answers the only question the check asks, + // which is whether the service exists at all. That same emptiness is why + // `build_reusable_provider` hands back nothing for a provider built from + // those signals, leaving it to be resolved per request against the + // signals that request actually carried. + let ec_provider = build_reusable_provider( + &settings.ec, + Some(Arc::new(FastlyHostSignals::default())), + None, + )?; + let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -226,6 +266,7 @@ pub(crate) fn build_state_from_settings( registry: Arc::new(registry), default_kv_store, auction_telemetry_sink, + ec_provider, })) } @@ -293,7 +334,24 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); - RuntimeServices::builder() + // The TLS JA4 and HTTP/2 signals arrive as trusted internal headers + // injected by the entry point. They build the host-signal service a + // host-signal provider reads. Fastly always supplies the capability, so the + // service is always set even when a request carried no signal. + let tls_ja4 = ctx + .request() + .headers() + .get("x-ts-tls-ja4") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let h2_fingerprint = ctx + .request() + .headers() + .get("x-ts-h2-fingerprint") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + + let builder = RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(Arc::clone(&state.default_kv_store)) @@ -304,10 +362,23 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler)) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) - .geo(Arc::new(FastlyPlatformGeo)) + .geo(build_geo_provider( + &state.settings, + Arc::new(FastlyPlatformGeo), + )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) - .build() + .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))); + + // Hand every request the provider resolved at the composition root, so the + // request path reuses that instance instead of resolving `[ec] provider` + // again. Nothing is set for a deployment that selects no provider, or one + // whose provider is built from this request's own host signals, and both + // are resolved on the request path instead. + match state.ec_provider.clone() { + Some(provider) => builder.resolved_ec_provider(provider).build(), + None => builder.build(), + } } fn publisher_fallback_methods() -> [Method; 7] { @@ -429,7 +500,7 @@ fn build_ec_request_state( req: &Request, ) -> EcRequestState { let device_signals = device_signals_for(req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; if !is_real_browser { log::info!( "Bot gate: blocking EC operations (ja4={:?}, platform={:?}, is_mobile={})", @@ -591,7 +662,12 @@ async fn execute_named( // copy is bot-gated, while operators use curl for this // authenticated diagnostic. let kv = crate::maybe_identity_graph(&state.settings); - handle_admin_ec_lookup(kv.as_ref(), ®istry, &req) + // The selected provider decides which identifiers this + // deployment recognizes, so build it here rather than + // assuming the built-in HMAC shape. The read-only + // diagnostic builds no EC request state to borrow it from. + let provider = request_provider(&state.settings.ec, &services)?; + handle_admin_ec_lookup(kv.as_ref(), ®istry, provider.as_deref(), &req) } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), _ => unreachable!("admin diagnostics should use early dispatch"), @@ -748,14 +824,18 @@ async fn run_named_route( /// response finalization. fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> Response { let device_signals = device_signals_for(&req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; let eids_cookie = crate::extract_cookie_value(&req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); let result = crate::require_identity_graph(&state.settings).and_then(|kv| { let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, &partner_registry, &limiter, req) + // A partner echoes back an identifier the deployment's own provider + // created, so validation and KV normalization are dispatched through + // that provider rather than the built-in HMAC grammar. + let provider = request_provider(&state.settings.ec, services)?; + handle_batch_sync(&kv, &partner_registry, &limiter, provider.as_deref(), req) }); let mut response = result.unwrap_or_else(|e| http_error(&e)); @@ -861,7 +941,7 @@ async fn dispatch_fallback( .ec_context .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) { - log::warn!("EC generation failed for publisher proxy: {err:?}"); + log::error!("EC generation failed for publisher proxy: {err:?}"); } // Publisher pages read consent data, so the consent KV store must be @@ -1505,6 +1585,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1576,6 +1659,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1651,6 +1737,11 @@ mod tests { let registry = IntegrationRegistry::from_request_filters(filters); let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; + // Resolved the same way the composition root resolves it, so this + // router behaves like a served one. + let ec_provider = + trusted_server_core::ec::provider::build_reusable_provider(&settings.ec, None, None) + .expect("should resolve the Edge Cookie provider selection"); let state = Arc::new(super::AppState { auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, @@ -1659,6 +1750,7 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + ec_provider, }); TrustedServerApp::routes_for_state(&state) } @@ -2043,6 +2135,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2696,6 +2791,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -3117,6 +3215,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 367a3e33f..26eb4e47b 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -7,14 +7,16 @@ use edgezero_core::app::Hooks as _; use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Request as HttpRequest, Response as HttpResponse}; +use edgezero_core::http::{ + HeaderMap, HeaderValue, Request as HttpRequest, Response as HttpResponse, header, +}; use edgezero_core::response::IntoResponse; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::cache_policy::EdgeCacheHeader; -use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals, build_device_provider}; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ @@ -22,12 +24,14 @@ use trusted_server_core::ec::pull_sync::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; -use trusted_server_core::platform::PlatformGeo as _; use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::platform::build_geo_provider; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; +use trusted_server_device_fastly::{FastlyDeviceProvider, FastlyHostSignals}; mod app; mod backend; @@ -168,7 +172,42 @@ fn edgezero_main(mut req: FastlyRequest) { // request extensions for build_per_request_services and EC bot classification. let client_info = client_info_from_request(&req, resolved_client_ip); let client_ip = client_info.client_ip; - let device_signals = derive_device_signals(&req); + + // Strip and re-inject the TLS JA4 and HTTP/2 signals from the + // authoritative Fastly SDK values, under the same trust model, so the + // EdgeZero app path can build the host-signal service from these internal + // headers (the SDK accessors return real values only on the live client + // request, not on a request rebuilt from EdgeZero HTTP types). + req.remove_header("x-ts-tls-ja4"); + req.remove_header("x-ts-h2-fingerprint"); + // Take ownership before setting: unlike the static TLS protocol/cipher + // names, these accessors borrow the request, which would otherwise conflict + // with the mutable `set_header`. + if let Some(ja4) = req.get_tls_ja4().map(str::to_string) { + req.set_header("x-ts-tls-ja4", ja4); + } + if let Some(h2) = req.get_client_h2_fingerprint().map(str::to_string) { + req.set_header("x-ts-h2-fingerprint", h2); + } + + // Derive device signals from the original FastlyRequest before conversion. + // Fastly's `get_tls_ja4()` and `get_client_h2_fingerprint()` accessors only + // return real values on the client request; a synthetic request rebuilt from + // EdgeZero HTTP types cannot expose them, which would strip the JA4/H2 class + // the EC bot gate needs and misclassify real browsers as bots. Stored in the + // request extensions so `build_ec_request_state` reads the authoritative + // signals instead of re-deriving from the reconstructed request. + // Reuse the settings snapshot already loaded for the app state rather than + // fetching and validating the config-store blob a second time per request. + let device_signals = match settings_snapshot.as_deref() { + Some(settings) => derive_device_signals(settings, &req), + None => { + log::warn!( + "EdgeZero device signals: settings unavailable, using UA-only classification" + ); + DeviceSignals::derive_ua_only(req.get_header_str("user-agent").unwrap_or("")) + } + }; // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such @@ -286,8 +325,12 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { + // Route through the [geo] provider selector, so a deployment that opts + // out of geolocation makes no host geo call on the entry-point finalize + // path either. + let geo = build_geo_provider(settings, Arc::new(FastlyPlatformGeo)); let geo_info = resolve_geo_for_response(response, client_ip, |client_ip| { - FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + geo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("entry-point geo lookup failed: {e}"); None }) @@ -493,16 +536,33 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option DeviceSignals { - let ua = req.get_header_str("user-agent").unwrap_or(""); - let ja4 = req.get_tls_ja4(); - let h2_fp = req.get_client_h2_fingerprint(); - - DeviceSignals::derive(ua, ja4, h2_fp) +/// The providers read request data from injected services. Device +/// classification reads only the User-Agent, borrowed here through a +/// `BorrowedRequestInfo`, unless `fastly` is selected, in which case the Fastly +/// provider also reads the TLS and HTTP/2 signals captured into a +/// [`FastlyHostSignals`]. The Fastly entry point still reads those TLS and +/// HTTP/2 signals on every request to build the host-signal service and client +/// info, so the capture is not conditional on the provider selection. +pub(crate) fn derive_device_signals(settings: &Settings, req: &FastlyRequest) -> DeviceSignals { + let mut headers = HeaderMap::new(); + if let Some(value) = req + .get_header_str(header::USER_AGENT.as_str()) + .and_then(|user_agent| HeaderValue::from_str(user_agent).ok()) + { + headers.insert(header::USER_AGENT, value); + } + let client_ip = req + .get_client_ip_addr() + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let request_info = BorrowedRequestInfo::new(&client_ip, None).with_headers(&headers); + build_device_provider(settings, || { + let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); + Box::new(FastlyDeviceProvider::new(host_signals)) as Box + }) + .detect(&request_info) } #[cfg(test)] @@ -529,6 +589,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 283f16255..060345787 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -320,6 +320,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index a29b48fda..322596102 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -9,18 +9,16 @@ use bytes::Bytes; use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; -use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, - PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, - StoreName, + BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, + PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, + PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, }; use trusted_server_core::settings::TrustedClientIpConfig; @@ -630,33 +628,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. -/// -/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so -/// that field mapping is never duplicated. -fn geo_from_fastly(geo: &Geo) -> GeoInfo { - GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - asn: None, - } -} - -/// Fastly geo-lookup implementation of [`PlatformGeo`]. -pub struct FastlyPlatformGeo; - -impl PlatformGeo for FastlyPlatformGeo { - fn lookup(&self, client_ip: Option) -> Result, Report> { - Ok(client_ip - .and_then(geo_lookup) - .map(|geo| geo_from_fastly(&geo))) - } -} +/// The Fastly host geo provider now lives in its own crate, +/// `trusted-server-geo-fastly`, so every provider implementation sits under +/// `crates//`. It is re-exported here so this module's +/// [`build_runtime_services`] and the adapter's existing call sites keep +/// referring to it through `crate::platform`. +pub(crate) use trusted_server_geo_fastly::FastlyPlatformGeo; fn single_utf8_header<'a>(req: &'a Request, name: &str) -> Option<&'a str> { let mut values = req.get_header_all(name); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 290de5aec..792fe32a3 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -24,6 +24,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -67,14 +68,32 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. /// +/// Settings are read from the platform config store at run time, the same way +/// the Fastly and Axum adapters read them, so an operator publishes one with +/// `ts config push` and the deployed component picks it up. This +/// adapter previously compiled `trusted-server.example.toml` into the binary +/// and parsed it here, which could never succeed, because that template ships +/// placeholder secrets and the placeholder admin password fails validation. +/// /// # Errors /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. +/// Returns an error when the config store holds no readable app config, or when +/// settings, the auction orchestrator, or the integration registry fail to +/// initialize. fn build_state() -> Result, Report> { let settings = load_startup_settings()?; build_state_with_settings(settings) @@ -118,11 +137,20 @@ fn load_startup_settings() -> Result> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { + // Composition root: resolve the provider selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // supplies no host signals and injects no vendor Edge Cookie provider, so + // both arguments are `None`, and each is passed here once this adapter + // supplies it. + let ec_provider = build_reusable_provider(&settings.ec, None, None)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Spin)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -132,6 +160,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -391,13 +420,26 @@ fn health_response() -> Response { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Spin's platform geo is a no-op, so jurisdiction stays Unknown unless -/// the request carries TCF consent. A malformed consent string is logged and -/// falls back to the default (fail-closed) context rather than being silently -/// swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +/// Spin's platform geo is a no-op, so jurisdiction stays Unknown unless the +/// request carries TCF consent, and a geo lookup failure is logged and treated +/// as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -406,10 +448,6 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } fn admin_key_management_not_supported() -> Response { @@ -550,6 +588,14 @@ impl TrustedServerApp { } } +/// Builds the per-request services, carrying the Edge Cookie provider the +/// composition root already resolved so the request path does not resolve +/// `[ec] provider` a second time. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx, &state.settings) + .with_resolved_ec_provider(state.ec_provider.clone()) +} + fn build_router(state: &Arc) -> RouterService { { let state = Arc::clone(state); @@ -559,7 +605,7 @@ fn build_router(state: &Arc) -> RouterService { let discovery_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_trusted_server_discovery(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -571,7 +617,7 @@ fn build_router(state: &Arc) -> RouterService { let verify_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_verify_signature(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -604,7 +650,7 @@ fn build_router(state: &Arc) -> RouterService { let auction_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed @@ -621,8 +667,13 @@ fn build_router(state: &Arc) -> RouterService { } // Build the geo-aware EC context so the auction consent gate sees // the caller's jurisdiction — `EcContext::default()` fails it - // closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); + // closed for consented users. When identity cannot be + // established at all, answer with an error rather than running + // the auction with no identity. + let mut ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; Ok(handle_auction( &s.settings, &s.orchestrator, @@ -642,7 +693,7 @@ fn build_router(state: &Arc) -> RouterService { let page_bids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( @@ -652,7 +703,13 @@ fn build_router(state: &Arc) -> RouterService { { return Ok(http_error(&error)); } - let mut ec_context = build_ec_context(&s.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than re-running the auction with no identity. + let mut ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -677,7 +734,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_proxy_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy(&s.settings, &services, req) .await @@ -690,7 +747,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_click_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_click(&s.settings, &services, req) .await @@ -703,7 +760,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_sign_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await @@ -720,7 +777,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok( handle_first_party_proxy_rebuild(&s.settings, &services, req) @@ -736,7 +793,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); @@ -775,7 +832,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -908,6 +971,9 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; fn multi_provider_settings() -> Settings { @@ -1004,6 +1070,93 @@ mod tests { ); } + #[test] + fn build_state_takes_its_settings_from_the_platform_config_store() { + // This adapter used to compile the shipped example template into the + // binary and parse it here. That template carries placeholder secrets + // by design, and the placeholder admin password fails + // `validate_admin_handler_passwords`, so `build_state` could never + // return `Ok` and the router fell back to the start-up error handler + // that answers every request with 503. Nothing caught it because every + // other test enters through the `routes_with_settings` parity seam and + // never calls this function. + // + // There is no Spin runtime under `cargo test`, so there are no + // component variables to read and this cannot return `Ok` here. What it + // must never do again is fail because of a configuration baked into the + // binary, so the failure has to be the absence of a config store and + // nothing else. + let Err(error) = build_state() else { + return; + }; + let message = format!("{error:?}"); + assert!( + message.contains("config store"), + "build_state should fail only for want of a config store, got: {message}" + ); + assert!( + !message.to_lowercase().contains("password"), + "build_state must not fail on a configuration compiled into the binary, got: {message}" + ); + } + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.acme]` block that provider's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. The settings are + /// parsed directly, bypassing the composition root's startup check, so the + /// per-request behavior can be exercised with a selection the adapter + /// cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // No resolved provider is threaded here, so the request path resolves + // the selection itself, which is what an embedder driving core + // directly does and where the loud failure has to stay. + let services = build_runtime_services(&ctx, &settings); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } + #[test] fn scheme_host_from_spin_url_extracts_localhost_with_port() { assert_eq!( diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index d7a09987a..864d4961d 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -236,6 +236,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index a81911186..60006b2df 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -708,7 +708,10 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { /// before routing. Secrets are read synchronously from Spin component /// variables because Trusted Server's platform secret trait is sync. #[must_use] -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -737,7 +740,13 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(NullGeo)) + // Routed through the [geo] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. Spin has no host + // geo service, so the host default resolves nothing either way. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::new(NullGeo), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -1044,7 +1053,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_runtime_services_uses_noop_native_stores_without_handles() { let ctx = make_ctx_without_spin_context(); - let services = build_runtime_services(&ctx); + let services = + build_runtime_services(&ctx, &trusted_server_core::settings::Settings::default()); assert!( services.client_info().client_ip.is_none(), diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f75ea687e..e4ca7f03e 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,6 +35,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -973,3 +976,54 @@ async fn admin_deactivate_key_auth_fail_returns_401() { "admin/keys/deactivate without credentials must return 401" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.acme]` block that provider's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a provider knows what that provider needs, and this adapter +/// injects no vendor Edge Cookie provider, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-core/README.md b/crates/trusted-server-core/README.md index 69575b0d2..1fe656eb6 100644 --- a/crates/trusted-server-core/README.md +++ b/crates/trusted-server-core/README.md @@ -51,7 +51,7 @@ Behavior is covered by an extensive test suite in `crates/trusted-server-core/sr - The `ec/` module owns the EC identity subsystem: - `ec/generation.rs` — creates HMAC-based IDs using the client IP and publisher passphrase (format: `64hex.6alnum`). - - `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`), `get_ec_id` helper. + - `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`). - `ec/consent.rs` — EC-specific consent gating wrapper. - `ec/cookies.rs` — `Set-Cookie` header creation and expiration helpers. - `publisher.rs::handle_publisher_request` issues the `ts-ec` cookie when absent so the browser keeps the identifier on subsequent requests. diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c0c0a7792..38af9d29f 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -303,8 +303,13 @@ pub async fn handle_auction( // EC and both KV and partner stores are available. Gate the read on a // present registry: without one, `resolve_auction_eids` yields no // server-side EIDs, so the snapshot would be an unused billable KV read. + // The row is read under the owning provider's canonical form of the + // identifier, the key it is stored under, rather than under the identifier + // as issued. let auction_kv_snapshot = match (kv, ec_id.as_deref(), registry) { - (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id), + (Some(graph), Some(_), Some(_)) => ec_context + .ec_kv_key() + .map_or(EcKvSnapshot::NotRead, |kv_key| graph.load_snapshot(&kv_key)), _ => EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response finalization — @@ -451,7 +456,13 @@ pub(crate) fn resolve_auction_eids( let ec_id = ec_context.ec_value()?; - let Some(entry) = snapshot.entry_for(ec_id) else { + // Callers read the snapshot under the identity-graph key, the owning + // provider's canonical form of the identifier, so the entry is looked up + // under that key rather than under the identifier as issued. + let Some(entry) = ec_context + .kv_key_for(ec_id) + .and_then(|kv_key| snapshot.entry_for(&kv_key)) + else { return Some(Vec::new()); }; @@ -624,6 +635,7 @@ mod tests { use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingProvider}; use crate::error::IntoHttpResponse as _; use crate::openrtb::Uid; use crate::platform::test_support::{ @@ -796,6 +808,85 @@ mod tests { ); } + #[tokio::test] + async fn auction_endpoint_loads_the_row_under_the_canonical_key() { + // The identity graph stores a row under the owning provider's + // canonical form of the identifier. Loaded and resolved under the + // identifier as issued, a provider whose canonical form differs from + // the cookie value found no row, so the auction carried no server-side + // EIDs and the context kept a snapshot bound to the wrong key. + let settings = create_test_settings(); + let had_eids = Arc::new(std::sync::Mutex::new(None)); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::clone(&had_eids), + })); + let registry = PartnerRegistry::from_config(&[counting_test_partner("ssp.example.com")]) + .expect("should build partner registry"); + let graph = KvIdentityGraph::in_memory("canonical-auction-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should seed the row under the canonical key"); + let mut ec_context = + make_ec_context(Jurisdiction::NonRegulated, Some(CANONICAL_COOKIE_VALUE)) + .with_provider_for_test(Arc::new(CanonicalizingProvider)); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + // The capturing provider records whether the request carried EIDs and + // then fails its launch, which is all this test needs. The request + // carries no client EIDs, so any EID it records came from the graph. + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + + assert!( + ec_context + .kv_snapshot() + .entry_for(CANONICAL_KV_KEY) + .is_some(), + "the endpoint should load the row stored under the canonical key" + ); + assert_eq!( + *had_eids.lock().expect("should lock captured eids"), + Some(true), + "the auction should carry the canonical row's partner ID as an EID" + ); + } + /// Provider that fails the test if it is ever contacted. Used to prove the /// `/auction` consent gate short-circuits before any outbound bid request. struct PanicOnBidProvider; diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 6be0c9cfa..5837807fb 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -11,8 +11,9 @@ use std::borrow::Cow; use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use validator::{Validate, ValidationError, ValidationErrors}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; +use crate::ec::provider::{HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY}; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::integrations::{ @@ -32,7 +33,9 @@ use crate::integrations::{ sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; -use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings}; +use crate::settings::{ + AssetOriginAuth, Ec, IntegrationConfig, PROVIDER_IMPLEMENTATION_KEY, Settings, +}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] @@ -117,6 +120,7 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = self.settings.validate().err().unwrap_or_default(); + remove_labeled_provider_secret_errors(&mut errors, &self.settings.ec); if let Err(report) = validate_settings_for_deploy(&self.settings) { errors.add( DEPLOY_VALIDATION_FIELD, @@ -131,6 +135,100 @@ impl Validate for TrustedServerAppConfig { } } +/// Removes the passphrase checks on Edge Cookie provider blocks written under +/// a label. +/// +/// Push-time validation reads a configuration whose secret fields hold +/// secret-store key names rather than the secrets themselves, so a value check +/// such as the 32-byte passphrase minimum would be judging a key name. +/// `EdgeZero`'s `validate_excluding_secrets` removes those checks for the +/// leaves [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) +/// lists, which covers the `[ec.hmac]` block. A block under a label of the +/// operator's choosing has no fixed path that list can hold, so its check is +/// removed here instead. The check itself is unchanged, and runs wherever +/// settings are loaded with their secrets resolved. +fn remove_labeled_provider_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { + let Some(ValidationErrorsKind::Struct(ec_errors)) = errors.errors_mut().get_mut("ec") else { + return; + }; + let labeled = ec + .provider_blocks + .hmac_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HMAC_PROVIDER_KEY) + .chain( + ec.provider_blocks + .host_signals_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HOST_SIGNALS_PROVIDER_KEY), + ); + for name in labeled { + let Some(ValidationErrorsKind::Struct(block_errors)) = ec_errors.errors_mut().get_mut(name) + else { + continue; + }; + block_errors.errors_mut().remove("passphrase"); + if block_errors.errors().is_empty() { + ec_errors.errors_mut().remove(name); + } + } + // An `ec` entry holding nothing would keep the whole result an error, the + // same reason `EdgeZero` prunes emptied containers after its own removals. + let ec_is_empty = ec_errors.errors().is_empty(); + if ec_is_empty { + errors.errors_mut().remove("ec"); + } +} + +impl crate::secret_resolution::ConfiguredSecretFields for TrustedServerAppConfig { + /// The passphrase of every Edge Cookie provider block that configures a + /// provider built into core under a label. + /// + /// [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) + /// lists the passphrases of the `[ec.hmac]` and `[ec.host_signals]` + /// blocks, the one path each of those providers' blocks has when its name + /// is its implementation. The same provider under a label of the + /// operator's choosing holds that secret at `ec.