diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3520952bd..bc390b3e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,8 +50,12 @@ jobs: run: cargo test-fastly test-axum: - name: cargo test (axum native) - runs-on: ubuntu-latest + name: cargo test (axum native, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -80,14 +84,23 @@ jobs: run: cargo bench -p trusted-server-core --bench html_processor_bench -- --test - name: Run host-target CLI tests + # The CLI is a host-target binary tested with an explicit host triple. + # Run it on the Linux runner only; the Windows matrix entry exists to + # verify the Axum adapter build and tests, and does not install the + # x86_64-unknown-linux-gnu target this step needs. + if: matrix.os == 'ubuntu-latest' run: cargo test --package trusted-server-cli --target x86_64-unknown-linux-gnu - name: Verify Fastly WASM release build run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 test-cloudflare: - name: cargo check (cloudflare native + wasm32-unknown-unknown) - runs-on: ubuntu-latest + name: cargo check (cloudflare native + wasm32-unknown-unknown, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index dabeec55d..e58bca7f7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ /spin /spin.sig +# logs +*.log + # EdgeZero local KV store (created by edgezero-adapter-axum framework) .edgezero/ diff --git a/CLAUDE.md b/CLAUDE.md index 2c902a7c4..dfeed6e37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,10 @@ crates/ trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) trusted-server-adapter-spin/ # Fermyon Spin entry point (wasm32-wasip1 component) trusted-server-cli/ # Host-target `ts` operator CLI + device/ + fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider) + edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC default is in core) + geo/ # vendor geo provider crates (host geo is injected by the adapter) trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -57,7 +61,9 @@ fastly compute serve # Deploy to Fastly fastly compute publish -# Run Axum dev server (native — no Viceroy) +# Run Axum dev server (native — no Viceroy). Settings load at runtime from the +# platform config store on every adapter; publish an operator config with +# `ts config push` (see trusted-server.example.toml for the template). cargo run -p trusted-server-adapter-axum # Test Axum adapter only @@ -138,6 +144,20 @@ cd crates/trusted-server-js/lib && node build-all.mjs cargo install viceroy --version 0.17.0 --locked --force ``` +### Windows (use WSL for the Linux-only tests) + +The Rust adapter tests run natively on Windows through the cargo aliases +(`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`), +and CI runs these on both `ubuntu-latest` and `windows-latest`. + +The Docker-based integration suite (`scripts/integration-tests.sh`) and the +Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`, +which uses `worker-build` + `wrangler dev`) are Linux tools. On Windows run them +inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the +WSL distro with the same toolchain as `.tool-versions` (rustup + the +`wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then +run the scripts from a clone on the WSL native filesystem for fast builds. + --- ## Coding Conventions @@ -265,12 +285,34 @@ impl core::error::Error for MyError {} ## Other guidelines +- Use US English spelling everywhere: code, identifiers, comments, + documentation, tests, commit messages, and configuration. For example, write + `color`, `behavior`, and `optimize`, not `colour`, `behaviour`, or `optimise`. + Where a term comes from an external source (for example the IAB TCF purpose + names), match that source's spelling even when it is not US English. - Use only example or fictional information in comments, tests, docs, examples, and similar non-runtime materials. (eg. for urls use: example.com domains only) - Do not write or commit real domains, customer names, credentials, configuration values, or other potentially sensitive real-world information in comments, tests, docs, or examples. +### Permission model terminology + +Permissions are the primitive. A provider declares the permissions it requires +(`required_permissions`) and the system decides whether each is _set_. Consent +is only one of several ways a permission may be established. Country or +jurisdiction rules (a `Granted` group baseline), legitimate interest, or +configuration can set a permission with no consent at all. + +- A provider that needs nothing **requires no permission**. Never write that it + "runs without any consent". +- A gated provider **runs once its required permissions are set**, by whatever + method. +- Reserve "consent" for the consent subsystem (`consent/`, `ConsentContext`, + GDPR and TCF strings) where it genuinely means a consent signal. In the + permission layer prefer "permission", "set" / "unset", and "signal" (consent + is one kind of signal, alongside privacy and opt-out signals). + --- ## Git Commit Conventions @@ -287,6 +329,41 @@ Bad: `"fix: added feature flags"` --- +## Provider Architecture + +Each vendor-differentiated capability is pluggable behind its own trait, so a +deployment selects an implementation and the core stays neutral: + +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ---------------------------------------- | ------------------- | --------------------------------------- | ---------------------------- | +| Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/` | + +Principles for adding or changing a provider: + +- **Core stays neutral.** The trait and the host-neutral default live in + `trusted-server-core`. Host-specific and vendor implementations live in their + own crates and are injected by the adapter (for example `build_device_provider` + and `build_geo_provider`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Providers read request evidence, not a fixed parameter set.** A provider must + be able to see everything about the request it needs (User-Agent, headers, and + host signals such as the TLS JA4 and HTTP/2 fingerprints) through an evidence + abstraction rather than a hard-coded struct of fields. Host signals come from + the host (the Fastly SDK) and are opt-in, so a neutral provider triggers no + host fingerprint calls. +- **Providers are separated by capability but composed per request, and one may + need another's output.** Geo resolves the country and region the permission + model uses, and the permission model gates whether the Edge Cookie provider + runs. Device signals gate Edge Cookie writes (the browser / bot gate). When + several vendor providers share a backend (for example a vendor's Edge Cookie, + geo, and device provider on one cloud pipeline) they share a single call per + request rather than calling independently. Give a provider the inputs and + upstream results it needs explicitly, rather than having it reach into globals. + +--- + ## Integration System Integrations register in Rust via: @@ -320,7 +397,7 @@ IntegrationRegistration::builder(ID) | --------------------- | ---------------------------------------------------------- | | `edgezero.toml` | EdgeZero app/platform manifest and logical stores | | `fastly.toml` | Fastly service configuration and build settings | -| `trusted-server.example.toml` | Source-controlled Trusted Server app-config template | +| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and `[geo] default_country`) | | `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | | `rust-toolchain.toml` | Pins Rust version to 1.95.0 | | `.env.dev` | Local development environment variables | diff --git a/Cargo.lock b/Cargo.lock index c77167838..a81d80035 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4396,6 +4396,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5202,6 +5215,8 @@ dependencies = [ "serde", "serde_json", "trusted-server-core", + "trusted-server-device-fastly", + "trusted-server-geo-fastly", "url", "urlencoding", ] @@ -5296,6 +5311,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "subtle", "temp-env", @@ -5309,6 +5325,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" @@ -5442,6 +5475,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 3d139333e..e3e8f12c4 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", @@ -80,6 +82,7 @@ rustls-pemfile = "2" scraper = "0.24.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" +serde_yaml_ng = "0.10" sha2 = "0.10.9" simple_logger = "5" spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } @@ -93,6 +96,8 @@ tokio-rustls = "0.26" toml = "1.1" 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..3aa4cb04a --- /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 fingerprints; 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..09921ca19 --- /dev/null +++ b/crates/device/fastly/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "trusted-server-device-fastly" +version = "0.1.0" +authors = [] +edition = "2024" +publish = false +license = "Apache-2.0" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +fastly = { workspace = true } diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs new file mode 100644 index 000000000..822e4cc37 --- /dev/null +++ b/crates/device/fastly/src/lib.rs @@ -0,0 +1,97 @@ +//! 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 fingerprints, 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 fingerprints 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 fingerprints captured from a live Fastly request. +/// +/// Reads the TLS JA4 and HTTP/2 fingerprints 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 fingerprint values. + /// + /// Use this when the adapter has read the fingerprints 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 fingerprints 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 the fingerprint-strengthened +/// [`DeviceSignals::derive`], reading the User-Agent from its injected +/// [`RequestInfo`] and the TLS/HTTP-2 fingerprints 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..186b8c304 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,9 @@ +# 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 default provider (HMAC over the client IP) ships in +`trusted-server-core` (`ec::provider`), so no crate is needed for it. This +directory is a placeholder until a vendor provider is added. diff --git a/crates/geo/README.md b/crates/geo/README.md new file mode 100644 index 000000000..af09b0012 --- /dev/null +++ b/crates/geo/README.md @@ -0,0 +1,21 @@ +# 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`). The permission model keys its country and +region rules on these codes, matched case-insensitively, so the Fastly and +other providers feed the same 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..7330e778c --- /dev/null +++ b/crates/geo/fastly/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "trusted-server-geo-fastly" +version = "0.1.0" +authors = [] +edition = "2024" +publish = false +license = "Apache-2.0" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +error-stack = { workspace = true } +fastly = { workspace = true } diff --git a/crates/geo/fastly/src/lib.rs b/crates/geo/fastly/src/lib.rs new file mode 100644 index 000000000..a93f7dd72 --- /dev/null +++ b/crates/geo/fastly/src/lib.rs @@ -0,0 +1,45 @@ +//! 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`; selecting a vendor +//! geo provider replaces it. +//! +//! Unlike the pure-logic device provider, this crate calls the Fastly geo 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`; selecting a vendor geo provider replaces it. +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/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 8ad362a97..0134bb36f 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -142,7 +142,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c4bf7d990..01766e7fc 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -33,7 +33,13 @@ fn test_router() -> edgezero_core::router::RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should parse route test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 3b60cae3f..0a46bb1e2 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -157,7 +157,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 305559be8..f66b028d6 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -36,7 +36,13 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should parse route test settings"); @@ -84,7 +90,13 @@ fn make_router() -> RouterService { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "integration-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 8547fd519..f9e34cfa8 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -22,6 +22,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 ee0e94185..32fa14a0e 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -27,6 +27,7 @@ //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | //! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | +//! | POST | `/_ts/api/v1/ec/resolve` | [`handle_ec_resolve`] | //! | POST | `/auction` | [`handle_auction`] | //! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | //! | GET | `/first-party/click` | [`handle_first_party_click`] | @@ -97,11 +98,12 @@ use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::batch_sync::handle_batch_sync; -use trusted_server_core::ec::consent::ec_consent_withdrawn; +use trusted_server_core::ec::consent::ec_storage_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::registry::PartnerRegistry; +use trusted_server_core::ec::resolve::handle_ec_resolve; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -109,7 +111,9 @@ use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; -use trusted_server_core::platform::{ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices}; +use trusted_server_core::platform::{ + build_geo_provider, ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, +}; use trusted_server_core::proxy::{ handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, AssetProxyCachePolicy, @@ -126,6 +130,7 @@ use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_device_fastly::FastlyHostSignals; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ @@ -246,14 +251,35 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); + // The TLS JA4 and HTTP/2 fingerprints 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 fingerprint. + 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); + RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(Arc::clone(&state.default_kv_store)) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) - .geo(Arc::new(FastlyPlatformGeo)) + .geo(build_geo_provider( + &state.settings, + Arc::new(FastlyPlatformGeo), + )) .client_info(client_info) + .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))) .build() } @@ -376,7 +402,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={})", @@ -407,11 +433,14 @@ fn build_ec_request_state( }; // Bot gate: suppress KV-backed EC writes for unrecognized clients, except - // consent withdrawals. Revocations keep the write path so tombstones stay - // authoritative even for privacy-extension-heavy clients. + // when the request carries an explicit withdrawal signal. The write path + // stays open for withdrawal so tombstones remain authoritative even for + // privacy-extension-heavy clients that do not look like known browsers. A + // merely not-permitted (pre-consent or fail-closed) request writes nothing, + // so it does not need the graph. let kv_graph = crate::maybe_identity_graph(settings); let finalize_kv_graph = if setup_error.is_none() - && (is_real_browser || ec_consent_withdrawn(ec_context.consent())) + && (is_real_browser || ec_storage_withdrawn(ec_context.consent())) { kv_graph.clone() } else { @@ -581,6 +610,7 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::EcResolve => handle_ec_resolve(&state.settings, req, &ec.ec_context), NamedRouteHandler::Auction => { // The auction reads consent data, so the consent KV store must be // available — fail closed with 503 when it is configured but @@ -623,7 +653,7 @@ 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); @@ -925,6 +955,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + EcResolve, Auction, FirstPartyProxy, FirstPartyClick, @@ -1005,6 +1036,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + NamedRoute { + path: "/_ts/api/v1/ec/resolve", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::EcResolve, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -1082,7 +1118,7 @@ impl TrustedServerApp { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new( Arc::clone(&state.settings), - Arc::new(FastlyPlatformGeo), + build_geo_provider(&state.settings, Arc::new(FastlyPlatformGeo)), )) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); @@ -1175,7 +1211,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1240,7 +1282,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1558,7 +1606,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -1724,6 +1778,25 @@ mod tests { ); } + #[test] + fn dispatch_ec_resolve_routes_to_resolve_handler() { + // Parity guard: POST /_ts/api/v1/ec/resolve must reach the resolve + // handler, not the publisher fallback or a router-level 405. The test + // settings configure no client-cycle provider, so the handler returns + // 204, proving the request was handled here rather than proxied to the + // publisher origin (which would error without a live backend). + let router = test_router(); + let response = + block_on(router.oneshot(empty_request(Method::POST, "/_ts/api/v1/ec/resolve"))) + .expect("should route request"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "POST ec/resolve should reach the resolve handler and 204, not proxy to the publisher" + ); + } + #[test] fn dispatch_fallback_attaches_ec_finalize_state() { // The publisher fallback must thread EC finalize state to the entry @@ -2010,7 +2083,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -2067,7 +2146,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.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 489c4982d..7d675d260 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -7,7 +7,7 @@ use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; use edgezero_core::http::{ - header, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, + header, HeaderMap, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, }; use edgezero_core::response::IntoResponse; use error_stack::Report; @@ -21,8 +21,8 @@ use trusted_server_core::auction::AuctionOrchestrator; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::batch_sync::handle_batch_sync; -use trusted_server_core::ec::consent::ec_consent_withdrawn; -use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::consent::ec_storage_withdrawn; +use trusted_server_core::ec::device::{build_device_provider, DeviceProvider, DeviceSignals}; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; @@ -32,13 +32,14 @@ use trusted_server_core::ec::pull_sync::{ use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; +use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::is_navigation_request; use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; -use trusted_server_core::platform::PlatformGeo as _; +use trusted_server_core::platform::build_geo_provider; use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, @@ -55,6 +56,7 @@ use trusted_server_core::request_signing::{ }; use trusted_server_core::settings::Settings; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_device_fastly::{FastlyDeviceProvider, FastlyHostSignals}; mod app; mod backend; @@ -457,6 +459,23 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { // defaulting those fields to empty as the EdgeZero context alone would. let client_info = client_info_from_request(&req); + // Strip and re-inject the TLS JA4 and HTTP/2 fingerprints 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 @@ -464,7 +483,17 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { // 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. - let device_signals = derive_device_signals(&req); + // 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. The standard dispatch helpers @@ -617,8 +646,11 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { + // Route through the [geo] provider selector, so a deployment with no geo + // provider 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 }) @@ -760,9 +792,12 @@ fn legacy_main(mut req: FastlyRequest) { // any request-derived context or converting to the core HTTP types. compat::sanitize_fastly_forwarded_headers(&mut req); - let device_signals = derive_device_signals(&req); - let runtime_services = - build_runtime_services(&req, std::sync::Arc::clone(&state.default_kv_store)); + let device_signals = derive_device_signals(&state.settings, &req); + let runtime_services = build_runtime_services( + &req, + std::sync::Arc::clone(&state.default_kv_store), + &state.settings, + ); let http_req = compat::from_fastly_request(req); let route_result = futures::executor::block_on(route_request( @@ -972,7 +1007,7 @@ async fn route_request( mut req: HttpRequest, device_signals: DeviceSignals, ) -> Result> { - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; if !is_real_browser { log::info!( @@ -1064,14 +1099,13 @@ async fn route_request( ec_context.set_device_signals(device_signals); // Bot gate: suppress KV-backed EC writes for unrecognized clients, except - // consent withdrawals. Revocations need the KV graph so tombstones remain - // authoritative even for privacy-extension-heavy clients that do not look - // like known browsers. - // Build the KV identity graph once. The write-path (finalize_kv_graph) is - // also given to bots when they signal consent withdrawal so tombstones are - // authoritative even for privacy-extension-heavy clients. + // when the request carries an explicit withdrawal signal. The write path + // (finalize_kv_graph) is still given to bots on withdrawal so tombstones + // remain authoritative even for privacy-extension-heavy clients that do not + // look like known browsers. A merely not-permitted (pre-consent or + // fail-closed) request writes nothing, so it does not need the graph. let kv_graph = maybe_identity_graph(settings); - let finalize_kv_graph = if is_real_browser || ec_consent_withdrawn(ec_context.consent()) { + let finalize_kv_graph = if is_real_browser || ec_storage_withdrawn(ec_context.consent()) { kv_graph.clone() } else { None @@ -1472,16 +1506,32 @@ 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`, while the +/// Fastly provider also reads the TLS/H2 fingerprints captured into a +/// [`FastlyHostSignals`]. The Fastly provider, and so the fingerprint capture, is +/// built only when selected, so the default request path makes no Fastly-specific +/// fingerprint call. +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, Some(&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)] @@ -1504,7 +1554,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.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 ceb470b7d..f8bf53d28 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -285,7 +285,13 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.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 9b1a73422..3fbf4a68b 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -6,25 +6,25 @@ //! incoming Fastly request. use std::io::Read as _; -use std::net::IpAddr; use std::sync::Arc; 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_lookup, Geo}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + build_geo_provider, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; +use trusted_server_core::settings::Settings; +use trusted_server_device_fastly::FastlyHostSignals; // --------------------------------------------------------------------------- // FastlyPlatformConfigStore @@ -533,33 +533,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; // --------------------------------------------------------------------------- // Entry-point helper @@ -574,19 +553,34 @@ impl PlatformGeo for FastlyPlatformGeo { /// /// `kv_store` is an [`Arc`] opened by the caller for /// the primary KV store. Use [`open_kv_store`] to construct it. +/// +/// `settings` selects the geo provider via the `[geo] provider` selector, +/// defaulting to the Fastly platform geo lookup. #[must_use] pub fn build_runtime_services( req: &Request, kv_store: Arc, + settings: &Settings, ) -> RuntimeServices { + let geo = build_geo_provider(settings, Arc::new(FastlyPlatformGeo)); + let client_info = client_info_from_request(req); + // Reuse the fingerprints already captured into the client metadata as the + // injected host-signal service, so a host-signal provider reads them without + // another SDK call. Fastly always supplies the capability, so this is always + // set; a request that carried no fingerprint simply yields `None`. + let host_signals = FastlyHostSignals::new( + client_info.tls_ja4.clone(), + client_info.h2_fingerprint.clone(), + ); RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(kv_store) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) - .geo(Arc::new(FastlyPlatformGeo)) - .client_info(client_info_from_request(req)) + .geo(geo) + .client_info(client_info) + .host_signals(Arc::new(host_signals)) .build() } @@ -776,7 +770,7 @@ mod tests { #[test] fn build_runtime_services_client_info_is_none_without_tls() { let req = Request::get("https://example.com/"); - let services = build_runtime_services(&req, noop_kv_store()); + let services = build_runtime_services(&req, noop_kv_store(), &Settings::default()); assert!( services.client_info().tls_protocol.is_none(), @@ -791,7 +785,7 @@ mod tests { #[test] fn build_runtime_services_returns_cloneable_services() { let req = Request::get("https://example.com/"); - let services = build_runtime_services(&req, noop_kv_store()); + let services = build_runtime_services(&req, noop_kv_store(), &Settings::default()); let cloned = services.clone(); assert_eq!( diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index ce542a7c0..3ecac1e98 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -393,7 +393,13 @@ fn base_route_settings_toml() -> &'static str { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + default_country = "FR" + [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 62f83e1ea..6796d2f43 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -185,7 +185,13 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 943717e84..00ded336b 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,7 +35,13 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + default_country = "FR" "#, ) .expect("should parse route test settings"); diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index d593390bb..4d0595dfa 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -38,6 +38,7 @@ rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml_ng = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } toml = { workspace = true } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 000f1c482..9e1b9d701 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -6,7 +6,7 @@ use http::{header, Request, Response, StatusCode}; use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; -use crate::consent::gate_eids_by_consent; +use crate::consent::gate_eids_by_permissions; use crate::constants::COOKIE_TS_EIDS; use crate::ec::eids::{resolve_partner_ids, to_eids}; use crate::ec::kv::KvIdentityGraph; @@ -149,10 +149,9 @@ pub async fn handle_auction( // consent gating before attaching them to the auction request. let merged_eids = merge_auction_eids(client_eids, eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); - auction_request.user.eids = - gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + auction_request.user.eids = gate_eids_by_permissions(merged_eids, ec_context.permissions()); if had_eids && auction_request.user.eids.is_none() { - log::warn!("Auction EIDs stripped by TCF consent gating"); + log::warn!("Auction EIDs stripped: bidstream permissions not set"); } // Create auction context @@ -393,20 +392,17 @@ fn merge_auction_eids( #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; use crate::openrtb::Uid; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use serde_json::json; - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { - EcContext::new_for_test( + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( ec_value.map(str::to_owned), - ConsentContext { - jurisdiction, - ..ConsentContext::default() - }, + ConsentContext::default(), + ec_allowed, ) } @@ -414,7 +410,7 @@ mod tests { fn resolve_auction_eids_returns_none_without_kv() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(None, Some(®istry), &ec_context); assert!(result.is_none(), "should return None when KV is missing"); @@ -424,7 +420,7 @@ mod tests { fn resolve_auction_eids_returns_none_without_registry() { let kv = KvIdentityGraph::failing("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(Some(&kv), None, &ec_context); assert!( @@ -438,7 +434,7 @@ mod tests { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + let ec_context = make_ec_context(false, Some(&ec_id)); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -451,7 +447,7 @@ mod tests { fn resolve_auction_eids_returns_none_when_no_ec() { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context); assert!( @@ -465,7 +461,7 @@ mod tests { let kv = KvIdentityGraph::failing("nonexistent_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); // KV store doesn't exist, so the get() call will error — should return // empty Vec (degraded mode), not None. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 4499db7e3..14563c2f9 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -241,7 +241,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" +[geo] +default_country = "FR" + [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "production-secret-key-32-bytes-min" [[handlers]] diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ee269045..67fc1dcc6 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -82,7 +82,9 @@ mod tests { fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); - original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); + original.ec.providers.hmac = Some(crate::settings::HmacProviderConfig { + passphrase: Redacted::new("12345678901234567890123456789012".to_string()), + }); original.handlers[0].password = Redacted::new("true".to_string()); let reconstructed = settings_from_config_blob(&envelope_json(&original)) @@ -94,8 +96,22 @@ mod tests { "numeric-looking proxy secret should remain a string" ); assert_eq!( - reconstructed.ec.passphrase.expose(), - original.ec.passphrase.expose(), + reconstructed + .ec + .providers + .hmac + .as_ref() + .expect("should reconstruct the hmac provider") + .passphrase + .expose(), + original + .ec + .providers + .hmac + .as_ref() + .expect("should keep the hmac provider") + .passphrase + .expose(), "numeric-looking passphrase should remain a string" ); assert_eq!( diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index ebb5e032c..ddaae4817 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -54,6 +54,7 @@ use http::Request; use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; use crate::geo::GeoInfo; +use crate::permissions::{Permission, PermissionState}; /// Number of deciseconds in one day (86 400 seconds × 10). const DECISECONDS_PER_DAY: u64 = 86_400 * 10; @@ -323,7 +324,7 @@ fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> b /// Returns the effective decoded TCF consent for enforcement decisions. #[must_use] -fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { +pub(crate) fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { ctx.tcf.as_ref().or_else(|| { let g = ctx.gpp.as_ref()?; g.eu_tcf.as_ref() @@ -456,46 +457,38 @@ pub fn build_us_privacy_from_gpc(config: &ConsentConfig) -> Option( +pub fn gate_eids_by_permissions( eids: Option>, - consent_ctx: Option<&ConsentContext>, + permissions: &PermissionState, ) -> Option> { let eids = eids?; if eids.is_empty() { return None; } - let tcf = consent_ctx.and_then(effective_tcf); - - match tcf { - Some(tcf) if allows_eid_transmission(tcf) => Some(eids), - Some(_) => { - log::info!("EIDs stripped: TCF Purpose 1 or 4 consent missing"); - None - } - None => { - // No TCF data — if GDPR applies, block EIDs as a precaution. - if consent_ctx.is_some_and(|c| c.gdpr_applies) { - log::info!("EIDs stripped: GDPR applies but no TCF consent available"); - None - } else { - Some(eids) - } - } + if permissions.is_set(Permission::StoreOnDevice) + && permissions.is_set(Permission::SelectPersonalisedAds) + { + Some(eids) + } else { + log::info!( + "EIDs stripped: store-on-device or select-personalised-ads is not set in the resolved permissions" + ); + None } } @@ -503,110 +496,30 @@ pub fn gate_eids_by_consent( // EC consent gating // --------------------------------------------------------------------------- -/// Determines whether Edge Cookie (EC) creation is permitted based on the -/// user's consent and detected jurisdiction. +/// Returns `true` when the request carries a US-style storage/sale opt-out +/// signal (GPC, a GPP sale opt-out, or a US Privacy opt-out), independent of +/// jurisdiction. /// -/// The decision follows the jurisdiction's consent model: +/// This reports the signal only. Whether the opt-out changes a permission is +/// decided by the country/region map when the permission state is assembled: it +/// drops a `granted` baseline (for example a US opt-out state) and has nothing to +/// drop where the permission is `requires_signal`. Honoring it everywhere is +/// intentionally conservative. /// -/// - **GDPR (EU/UK)**: opt-in required — TCF Purpose 1 (store/access -/// information on a device) must be explicitly consented. If no TCF data is -/// available under GDPR, consent is assumed absent and EC is blocked. -/// - **US state privacy**: opt-out model — EC is allowed unless the user has -/// explicitly opted out via Global Privacy Control, GPP US sale opt-out, or -/// the US Privacy string. Explicit US opt-out signals take precedence over -/// TCF storage consent. -/// - **Non-regulated**: EC is allowed (no consent requirement). -/// - **Unknown**: fail-closed — jurisdiction cannot be determined so EC is -/// blocked as a precaution. +/// TCF consent or refusal is handled separately by +/// [`crate::ec::consent::permission_signal`], which treats a present TCF record +/// as authoritative, so this helper does not consider TCF. #[must_use] -pub fn allows_ec_creation(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - // EU/UK: explicit opt-in required (TCF Purpose 1 = store/access device). - match effective_tcf(ctx) { - Some(tcf) => tcf.has_storage_consent(), - None => false, - } - } - jurisdiction::Jurisdiction::UsState(_) => { - // GPC is an independent opt-out signal — it always blocks EC - // creation regardless of other consent signals. - if ctx.gpc { - return false; - } - // Explicit US opt-out signals take precedence over TCF storage - // consent in US-state jurisdictions. - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return false; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return false; - } - // When a CMP uses TCF in the US (e.g. Didomi), respect the TCF - // Purpose 1 decision if no explicit US opt-out signal is present. - if let Some(tcf) = effective_tcf(ctx) { - return tcf.has_storage_consent(); - } - // GPP US sale_opt_out=false is an explicit non-opt-out signal. - if let Some(gpp) = &ctx.gpp { - if let Some(opted_out) = gpp.us_sale_opt_out { - return !opted_out; - } - } - // Check US Privacy string when no TCF decision is present. - if let Some(usp) = &ctx.us_privacy { - return usp.opt_out_sale != PrivacyFlag::Yes; - } - // Spec §6.1.1: "In regulated jurisdictions (GDPR, US state), - // consent cookies/headers must be present for - // allows_ec_creation() to return true." No signals = block. - false - } - jurisdiction::Jurisdiction::NonRegulated => true, - // No geolocation data — cannot determine jurisdiction. - // Fail-closed: block EC creation as a precaution. - jurisdiction::Jurisdiction::Unknown => false, +pub fn has_storage_optout_signal(ctx: &ConsentContext) -> bool { + if ctx.gpc { + return true; } -} - -/// Returns `true` only when the request contains an explicit EC opt-out signal. -/// -/// This is intentionally narrower than [`allows_ec_creation`]. Some requests -/// fail closed because consent cannot be verified yet (for example, missing geo -/// or missing/undecodable consent signals in a regulated jurisdiction). Those -/// cases must block *new* EC creation, but they must not be treated as an -/// authoritative withdrawal of an already-issued EC. -#[must_use] -pub fn has_explicit_ec_withdrawal(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - effective_tcf(ctx).is_some_and(|tcf| !tcf.has_storage_consent()) - } - jurisdiction::Jurisdiction::UsState(_) => { - if ctx.gpc { - return true; - } - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return true; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return true; - } - if let Some(tcf) = effective_tcf(ctx) { - return !tcf.has_storage_consent(); - } - false - } - jurisdiction::Jurisdiction::NonRegulated | jurisdiction::Jurisdiction::Unknown => false, + if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { + return true; } + ctx.us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) } // --------------------------------------------------------------------------- @@ -682,8 +595,8 @@ mod tests { use http::Request; use super::{ - allows_ec_creation, apply_expiration_check, apply_tcf_conflict_resolution, - build_consent_context, build_context_from_signals, has_explicit_ec_withdrawal, + apply_expiration_check, apply_tcf_conflict_resolution, build_consent_context, + build_context_from_signals, gate_eids_by_permissions, has_storage_optout_signal, ConsentPipelineInput, }; use crate::consent::jurisdiction::Jurisdiction; @@ -786,7 +699,7 @@ mod tests { } #[test] - fn missing_geo_keeps_unknown_jurisdiction_and_blocks_ec_creation() { + fn missing_geo_keeps_unknown_jurisdiction() { let req = build_request(); let config = ConsentConfig::default(); @@ -804,10 +717,6 @@ mod tests { Jurisdiction::Unknown, "missing geo should keep jurisdiction unknown" ); - assert!( - !allows_ec_creation(&ctx), - "missing geo should keep EC creation fail-closed" - ); } #[test] @@ -963,408 +872,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // allows_ec_creation tests - // ----------------------------------------------------------------------- - - /// Helper: builds a TCF consent with configurable Purpose 1 (storage). - fn make_tcf_with_storage(has_storage: bool) -> TcfConsent { - TcfBuilder::new().with_storage(has_storage).build() - } - - #[test] - fn ec_allowed_gdpr_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(true)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 consented should allow EC" - ); - } - - #[test] - fn ec_blocked_gdpr_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(false)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 not consented should block EC" - ); - } - - #[test] - fn ec_blocked_gdpr_no_tcf_data() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: None, - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR with no TCF data should block EC" - ); - } - - #[test] - fn ec_allowed_gdpr_via_gpp_embedded_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(make_tcf_with_storage(true)), - us_sale_opt_out: None, - }), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + GPP embedded TCF with P1 consent should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_no_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + no opt-out should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + opt-out should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_implies_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPC=true with no US Privacy string should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_no_signals() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: false, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + no consent signals should block EC (spec \u{a7}6.1.1: fail-closed)" - ); - } - - #[test] - fn ec_allowed_non_regulated() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "non-regulated jurisdiction should always allow EC" - ); - } - - #[test] - fn ec_blocked_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "unknown jurisdiction should block EC (fail-closed when geo unavailable)" - ); - assert!( - !has_explicit_ec_withdrawal(&ctx), - "unknown jurisdiction should not be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC=true should block EC even when US Privacy says no opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPC=true should be treated as an explicit withdrawal signal" - ); - } - - #[test] - fn ec_us_privacy_not_applicable_allows_ec() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("VA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::NotApplicable, - opt_out_sale: PrivacyFlag::NotApplicable, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US Privacy with opt_out=N/A should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_tcf_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + TCF Purpose 1 consented should allow EC (Didomi-style CMP)" - ); - } - - #[test] - fn ec_blocked_us_state_tcf_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(false)), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + TCF Purpose 1 denied should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when TCF grants storage consent in US state" - ); - } - - #[test] - fn ec_blocked_us_state_us_privacy_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_allowed_us_state_gpp_no_sale_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=false should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpp_sale_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=true should block EC" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US sale opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_gpp_us() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpc: true, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when GPP US says no opt-out" - ); - } - - #[test] - fn ec_us_state_gpp_us_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPP US opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_us_privacy_opt_out_overrides_gpp_non_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should block EC even when GPP US has no sale opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: None, - us_sale_opt_out: None, - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GPP without US section should fall through to us_privacy" - ); - } - // ----------------------------------------------------------------------- // Consent KV read-fallback / write-on-change pipeline tests // ----------------------------------------------------------------------- @@ -1521,4 +1028,86 @@ mod tests { "should not persist consent without an EC ID" ); } + + #[test] + fn gate_eids_keeps_eids_when_required_permissions_are_set() { + // US maps to us-opt-out, where store-on-device and select-personalised-ads + // are granted with no signal, so bidstream EIDs are transmitted. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), None, |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_some(), + "EIDs should pass when store-on-device and select-personalised-ads are set" + ); + } + + #[test] + fn gate_eids_strips_eids_when_a_required_permission_is_unset() { + // FR maps to gdpr-eu, where every purpose is requires_signal, so with no + // signal neither required permission is set and EIDs are stripped. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("FR"), None, |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_none(), + "EIDs should be stripped when a required permission is not set" + ); + } + + #[test] + fn gate_eids_returns_none_for_empty_input() { + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), None, |_| false); + assert!( + gate_eids_by_permissions::(None, &permissions).is_none(), + "no EIDs should resolve to None" + ); + assert!( + gate_eids_by_permissions(Some(Vec::::new()), &permissions).is_none(), + "an empty EID list should resolve to None" + ); + } + + #[test] + fn has_storage_optout_signal_detects_us_style_opt_outs() { + let gpc = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + assert!(has_storage_optout_signal(&gpc), "GPC is a storage opt-out"); + + let gpp_sale_opt_out = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![8], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&gpp_sale_opt_out), + "a GPP US sale opt-out is a storage opt-out" + ); + + let usp_opt_out = ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::No, + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&usp_opt_out), + "a US Privacy sale opt-out is a storage opt-out" + ); + + assert!( + !has_storage_optout_signal(&ConsentContext::default()), + "no signal is not a storage opt-out" + ); + } } diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ad9f5dd29..9c2f54770 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -1,77 +1,222 @@ -//! EC-specific consent gating. +//! EC-specific permission gating, resolved through the permission model. //! -//! This module provides the public consent-check API for the EC subsystem. -//! The underlying logic lives in [`crate::consent::allows_ec_creation`]; this -//! wrapper exists so that EC callers can import from `ec::consent` and the -//! eventual migration path (renaming, adding EC-specific conditions) is -//! contained here. +//! The Edge Cookie provider advertises the [`Permission`]s its data use +//! requires. [`ec_permission_granted`] resolves which permissions are set for a +//! request, from its session signals and the country it maps to, and reports +//! whether every required permission is set. The EC permission decision lives +//! here, in the EC subsystem, and nowhere else, so callers route every EC +//! permission check through this module rather than re-deriving one. + +use std::sync::Arc; + +use error_stack::Report; use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::HostSignals; +use crate::permissions::{ConsentSignal, Permission, PermissionMaps, PermissionState}; +use crate::platform::GeoInfo; +use crate::settings::Settings; -/// Determines whether Edge Cookie creation is permitted based on the -/// user's consent and detected jurisdiction. +use super::provider::build_provider; + +/// Whether the configured Edge Cookie provider's required permissions are set +/// for this request. +/// +/// The permission state is assembled by [`assemble_permissions`] (the +/// country/region baseline augmented by the session's signals), and this gate +/// only asks whether every permission the provider requires is set. The gate +/// never inspects consent itself: that lives in the signal mapping, so the +/// decision depends solely on the resolved permissions. A request with no Edge +/// Cookie provider configured has nothing to gate, so this returns `true`. The +/// generation path still skips when no provider is built, so no Edge Cookie is +/// written in that case. /// -/// This is the canonical entry point for EC consent checks. It delegates -/// to [`crate::consent::allows_ec_creation`] today but may diverge as -/// EC-specific consent rules evolve. +/// # Errors +/// +/// Returns [`TrustedServerError`] when the selected provider requires a service +/// the host does not supply (for example the host-signal provider on a host with +/// no fingerprints), so a misconfigured deployment fails loudly rather than +/// silently treating the permission as ungranted. +pub fn ec_permission_granted( + settings: &Settings, + consent: &ConsentContext, + geo: Option<&GeoInfo>, + host_signals: Option>, +) -> Result> { + // The provider declares the permissions its data use requires. Build it to + // read that declaration; with no provider configured there is nothing to + // gate, so the check passes. Reading `required_permissions()` needs no request + // data, so no request info is threaded here. + let Some(provider) = build_provider(&settings.ec, host_signals)? else { + return Ok(true); + }; + Ok(assemble_permissions(settings, consent, geo).all_set(provider.required_permissions())) +} + +/// Assembles the permission state for a request: the country/region baseline +/// from the default maps in `permissions.yaml`, augmented by the session's +/// signals. /// -/// See [`crate::consent::allows_ec_creation`] for the full decision matrix. +/// Permissions exist without a consent model. With no signal present the result +/// is simply the baseline for the request's country and region. When the geo +/// provider returns no country, or a country/region that has no rule, the +/// deployer's configured `[geo] default_country` applies. A default is required, +/// so it is always available. #[must_use] -pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool { - crate::consent::allows_ec_creation(consent_context) +pub fn assemble_permissions( + settings: &Settings, + consent: &ConsentContext, + geo: Option<&GeoInfo>, +) -> PermissionState { + let maps = PermissionMaps::standard(); + let (default_country, default_region) = match settings.geo.default_country.as_deref() { + Some(spec) => match spec.split_once('/') { + Some((country, region)) => (Some(country), Some(region)), + None => (Some(spec), None), + }, + None => (None, None), + }; + maps.resolve_with( + geo.map(|info| info.country.as_str()), + geo.and_then(|info| info.region.as_deref()), + default_country, + default_region, + permission_signal(consent), + ) } -/// Returns `true` when the request carries an explicit EC withdrawal signal. +/// Maps a consent context to a [`ConsentSignal`] for each permission. /// -/// This is intentionally stricter than [`ec_consent_granted`]. A fail-closed -/// result such as unknown jurisdiction or missing consent data must not be -/// treated as an authoritative withdrawal of an already-issued EC. +/// This is the only place the EC subsystem reads consent signals, and it is +/// jurisdiction-free. A TCF record is authoritative wherever it is present (a CMP +/// under GDPR emits it): it grants or refuses each purpose it carries directly, +/// and a US-style opt-out does not override it. With no TCF record, a US-style +/// opt-out (GPC, GPP sale opt-out, or US Privacy opt-out) revokes the permission, +/// and anything else is neutral so the country/region baseline stands. +/// +/// Whether a `Revoke` changes anything is decided by the map: it drops a +/// `granted` baseline and has nothing to drop where the permission is +/// `requires_signal`. Only the two permissions that Edge Cookie identity and +/// bidstream EIDs depend on are resolved against a signal: +/// [`Permission::StoreOnDevice`] (TCF Purpose 1) and +/// [`Permission::SelectPersonalisedAds`] (TCF Purpose 4); every other permission +/// is neutral so its baseline stands. +fn permission_signal(consent: &ConsentContext) -> impl Fn(Permission) -> ConsentSignal + '_ { + move |permission| { + if let Some(tcf) = crate::consent::effective_tcf(consent) { + let consented = match permission { + Permission::StoreOnDevice => tcf.has_storage_consent(), + Permission::SelectPersonalisedAds => tcf.has_personalized_ads_consent(), + _ => return ConsentSignal::Neutral, + }; + return if consented { + ConsentSignal::Grant + } else { + ConsentSignal::Revoke + }; + } + if crate::consent::has_storage_optout_signal(consent) { + ConsentSignal::Revoke + } else { + ConsentSignal::Neutral + } + } +} + +/// Reports whether the request carries an explicit signal withdrawing Edge +/// Cookie storage, rather than merely lacking the permission. +/// +/// This separates an affirmative withdrawal (which expires the browser cookie +/// and writes the authoritative identity-graph tombstone) from a pre-consent or +/// fail-closed state where the permission is simply not set (which strips EC +/// response headers but must not destroy an already-issued identifier, or a +/// returning user would be permanently withdrawn before they ever get to +/// consent). A TCF record is authoritative where present: it is a withdrawal +/// when it refuses storage (Purpose 1). With no TCF record, a US-style storage +/// opt-out (GPC, GPP sale opt-out, or US Privacy) is a withdrawal. No signal at +/// all is not a withdrawal. #[must_use] -pub fn ec_consent_withdrawn(consent_context: &ConsentContext) -> bool { - crate::consent::has_explicit_ec_withdrawal(consent_context) +pub fn ec_storage_withdrawn(consent: &ConsentContext) -> bool { + if let Some(tcf) = crate::consent::effective_tcf(consent) { + return !tcf.has_storage_consent(); + } + crate::consent::has_storage_optout_signal(consent) } #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; + use crate::test_support::tests::create_test_settings; #[test] - fn ec_consent_granted_allows_non_regulated_requests() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, - ..ConsentContext::default() - }; - + fn no_edge_cookie_provider_is_vacuously_granted() { + // With no provider selected there are no required permissions to + // satisfy, so the check passes. The generation path still skips when no + // provider is built, so nothing is written to the device. + let mut settings = create_test_settings(); + settings.ec.provider = None; assert!( - ec_consent_granted(&ctx), - "non-regulated requests should be allowed" + ec_permission_granted(&settings, &ConsentContext::default(), None, None) + .expect("the gate should evaluate without error"), + "no provider means nothing to gate, so the check passes" ); } #[test] - fn ec_consent_granted_blocks_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, - ..ConsentContext::default() - }; + fn hmac_provider_is_blocked_without_storage_consent() { + // The test settings select the HMAC provider, which requires + // store-on-device. With no signal and no configured default country, + // that permission sits at the requires-signal floor, so it is not set and + // no Edge Cookie is written. + let settings = create_test_settings(); + assert!( + !ec_permission_granted(&settings, &ConsentContext::default(), None, None) + .expect("the gate should evaluate without error"), + "the floor should not run the HMAC provider without the permission set" + ); + } + + fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } + } + #[test] + fn no_signal_uses_the_us_opt_out_baseline() { + // US/CA maps to the us-opt-out group, where every purpose is granted + // without a signal, so EC identity and bidstream EIDs are both permitted. + let settings = create_test_settings(); + let state = assemble_permissions(&settings, &ConsentContext::default(), Some(&us_ca_geo())); assert!( - !ec_consent_granted(&ctx), - "unknown jurisdiction should fail closed" + state.is_set(Permission::StoreOnDevice) + && state.is_set(Permission::SelectPersonalisedAds), + "a US opt-out state should grant store-on-device and select-personalised-ads" ); } #[test] - fn ec_consent_withdrawn_does_not_treat_unknown_jurisdiction_as_revocation() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { + // A US-style opt-out drops a granted baseline with no jurisdiction match: + // the map granted these purposes, and GPC revokes them. + let settings = create_test_settings(); + let consent = ConsentContext { + gpc: true, ..ConsentContext::default() }; - + let state = assemble_permissions(&settings, &consent, Some(&us_ca_geo())); assert!( - !ec_consent_withdrawn(&ctx), - "unknown jurisdiction should block creation without revoking existing EC" + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted store-on-device and select-personalised-ads baseline" ); } } diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index 7fc3cab89..c716c9286 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -153,6 +153,46 @@ pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_ } } +/// Sets an Edge Cookie minted by a client-cycle provider on the response. +/// +/// Unlike [`set_ec_cookie`], the value is treated as opaque: it need not match +/// the canonical HMAC id shape, because a client-cycle provider's identifier +/// (for example a signed envelope, or the client-random demo's value) is not in +/// that format. The value is still validated against the RFC 6265 +/// `cookie-octet` rules via [`is_safe_cookie_value`]; an unsafe value is +/// rejected (no cookie set) and logged, which prevents header injection. The +/// same `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Domain` attributes +/// as [`set_ec_cookie`] apply. +pub(crate) fn set_provider_ec_cookie( + settings: &Settings, + response: &mut Response, + value: &str, +) { + if !is_safe_cookie_value(value) { + log::warn!( + "Rejecting provider Edge Cookie value of {} bytes: contains characters illegal in a cookie value", + value.len() + ); + return; + } + + let cookie = format_set_cookie( + &settings.publisher.ec_cookie_domain(), + value, + COOKIE_MAX_AGE, + ); + match HeaderValue::from_str(&cookie) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + // Unreachable in practice: is_safe_cookie_value gates the value and + // format_set_cookie emits only controlled bytes. + log::warn!("Skipping provider EC Set-Cookie: invalid header value: {e}"); + } + } +} + /// Expires the EC cookie by setting `Max-Age=0`. /// /// Used when a user revokes consent — the browser will delete the cookie @@ -377,4 +417,63 @@ mod tests { "expiry cookie should retain the same security attributes as the live cookie" ); } + + #[test] + fn set_provider_ec_cookie_sets_opaque_value_with_security_attributes() { + let settings = create_test_settings(); + let mut response = empty_response(); + // A client-cycle value that is not the canonical HMAC id shape. + set_provider_ec_cookie(&settings, &mut response, "8473625190"); + + let cookie_str = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be valid UTF-8"); + + assert_eq!( + cookie_str, + format!( + "{}=8473625190; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly", + COOKIE_TS_EC, settings.publisher.domain, COOKIE_MAX_AGE, + ), + "an opaque provider value should be set verbatim with the standard security attributes" + ); + } + + #[test] + fn set_provider_ec_cookie_preserves_base64_value() { + let settings = create_test_settings(); + let mut response = empty_response(); + // Base64 characters (`+`, `/`, `=`) are RFC 6265 cookie-safe and must be + // preserved verbatim, unlike the narrower HMAC outbound allowlist used by + // set_ec_cookie, which would strip them. + let value = "abcDEF123+/="; + set_provider_ec_cookie(&settings, &mut response, value); + + let cookie_str = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be valid UTF-8"); + + assert!( + cookie_str.starts_with(&format!("{COOKIE_TS_EC}={value};")), + "a base64 provider value should be preserved verbatim, got {cookie_str}" + ); + } + + #[test] + fn set_provider_ec_cookie_rejects_unsafe_value() { + let settings = create_test_settings(); + let mut response = empty_response(); + set_provider_ec_cookie(&settings, &mut response, "evil; Domain=.attacker.com"); + + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an unsafe value must not set a cookie, preventing header injection" + ); + } } diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index 7e540bcef..480b9e96b 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -1,9 +1,11 @@ //! Device signal derivation for bot detection and browser classification. //! -//! All functions in this module are pure computations — no KV I/O or Fastly -//! SDK calls. The Fastly adapter extracts raw strings from the request -//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`, UA header) and passes -//! them here for classification. +//! The [`DeviceSignals`] derivation here is pure computation, with no KV I/O or +//! Fastly SDK calls. A [`DeviceProvider`] is wired by dependency injection: its +//! constructor takes the services it reads (the [`RequestInfo`] for the +//! User-Agent, and on a fingerprinting host the +//! [`HostSignals`](crate::evidence::HostSignals) for the TLS/H2 fingerprints), +//! and classifies the request from them. //! //! # Signals //! @@ -18,6 +20,8 @@ use sha2::{Digest as _, Sha256}; use super::kv_types::KvDevice; +use crate::evidence::RequestInfo; +use crate::settings::Settings; /// Device signals derived from a single request. /// @@ -37,14 +41,44 @@ pub struct DeviceSignals { pub h2_fp_hash: Option, /// `true` = known browser, `false` = known bot, `None` = unknown. pub known_browser: Option, + /// Whether the request looks like a real browser, used to gate Edge Cookie + /// writes. Computed by the producing provider: the built-in provider uses a + /// User-Agent-only heuristic, while the Fastly provider strengthens it with + /// the TLS/H2 fingerprints. + pub looks_like_browser: bool, } impl DeviceSignals { - /// Derives all device signals from raw request data. + /// Derives device signals from the User-Agent alone, with no + /// host-specific TLS or HTTP/2 evidence. + /// + /// This is the default path: it touches no Fastly-specific API, so a + /// default deployment stays host-neutral. `ja4_class` and `h2_fp_hash` are + /// left absent, and the browser/bot decision uses a User-Agent-only + /// heuristic (`looks_like_browser_from_ua`). + #[must_use] + pub fn derive_ua_only(ua: &str) -> Self { + let platform_class = parse_platform_class(ua); + let looks_like_browser = looks_like_browser_from_ua(ua, platform_class.as_deref()); + + Self { + is_mobile: parse_is_mobile(ua), + ja4_class: None, + platform_class, + h2_fp_hash: None, + known_browser: None, + looks_like_browser, + } + } + + /// Derives device signals from the User-Agent strengthened with the + /// host's TLS/H2 fingerprints. /// /// `ua` is the `User-Agent` header value. `ja4` is the full JA4 hash /// from `req.get_tls_ja4()`. `h2_fp` is the raw H2 SETTINGS string - /// from `req.get_client_h2_fingerprint()`. + /// from `req.get_client_h2_fingerprint()`. These fingerprints are + /// host-specific (Fastly), so only the opt-in Fastly device provider + /// uses this path; the browser/bot gate then requires a TLS fingerprint. #[must_use] pub fn derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> Self { let is_mobile = parse_is_mobile(ua); @@ -52,6 +86,12 @@ impl DeviceSignals { let platform_class = parse_platform_class(ua); let h2_fp_hash = h2_fp.map(compute_h2_fp_hash); let known_browser = evaluate_known_browser(ja4_class.as_deref(), h2_fp_hash.as_deref()); + // The fingerprint-strengthened gate: a real browser produces a valid + // TLS fingerprint and a recognizable UA platform. Raw HTTP clients + // (curl, Python requests, Go net/http, headless scrapers) lack one or + // both. This is intentionally aimed at filtering obvious missing-signal + // traffic, not at resisting deliberate JA4 + UA spoofing. + let looks_like_browser = ja4_class.is_some() && platform_class.is_some(); Self { is_mobile, @@ -59,32 +99,10 @@ impl DeviceSignals { platform_class, h2_fp_hash, known_browser, + looks_like_browser, } } - /// Returns `true` when the request looks like a real browser. - /// - /// Checks for the presence of recognizable signals rather than matching - /// against a hardcoded fingerprint allowlist. Real browsers always - /// produce a valid TLS fingerprint (`ja4_class`) and a recognizable UA - /// platform string (`platform_class`). Raw HTTP clients (curl, Python - /// requests, Go net/http, headless scrapers) typically lack one or both. - /// - /// # Threat model - /// - /// This heuristic is intentionally aimed at filtering obvious - /// missing-signal traffic, not at resisting deliberate spoofing. A bot - /// that forges plausible JA4 and UA inputs may still pass; deeper - /// consistency checks can be added later if product requirements demand - /// stronger spoof resistance. - /// - /// `known_browser` is still computed and stored on [`KvDevice`] for - /// analytics but does not gate identity operations. - #[must_use] - pub fn looks_like_browser(&self) -> bool { - self.ja4_class.is_some() && self.platform_class.is_some() - } - /// Converts these signals into a [`KvDevice`] for KV storage. #[must_use] pub fn to_kv_device(&self) -> KvDevice { @@ -98,6 +116,88 @@ impl DeviceSignals { } } +/// A strategy for classifying a request into [`DeviceSignals`]. +/// +/// Implementations are selected by configuration. The built-in +/// [`BuiltinDeviceProvider`] is the default; a deployment can switch to another +/// provider without changing call sites. +/// +/// These signals serve identity gating and bot detection, not bid enrichment. +/// [`DeviceSignals`] deliberately carries only the coarse browser and bot +/// classification the Edge Cookie gate needs, not a full device-detection +/// result such as make, model, OS version, or screen size. A richer device +/// model for the ad request is a separate concern. +pub trait DeviceProvider: Send + Sync { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// Classifies the request into [`DeviceSignals`], reading the request data + /// it needs from the [`RequestInfo`] passed borrowed at call time (plus any + /// host signals injected into its constructor). + /// + /// Device signals gate identity operations and must always yield a value, + /// so this is infallible: a provider that cannot determine a signal returns + /// the unknown variant rather than failing the request. + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals; + + /// The permissions this provider's data use requires. + /// + /// The default is empty, so the built-in User-Agent-only provider requires + /// no permission. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } +} + +/// The built-in device provider, the default. +/// +/// Derives [`DeviceSignals`] from the User-Agent alone via +/// [`DeviceSignals::derive_ua_only`], touching no host-specific API. It reads +/// only [`RequestInfo::user_agent`] and never a host fingerprint, so the default +/// request path stays host-neutral. +#[derive(Debug, Default)] +pub struct BuiltinDeviceProvider; + +impl BuiltinDeviceProvider { + /// Creates the built-in provider. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl DeviceProvider for BuiltinDeviceProvider { + fn id(&self) -> &'static str { + "builtin" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only(request_info.user_agent()) + } +} + +/// Selects the device provider named by the `[device] provider` selector. +/// +/// Returns the built-in User-Agent-only provider unless the `fastly` selector is +/// set, in which case it builds the host-specific provider through the +/// `build_fastly` factory the adapter supplies. The factory runs only when that +/// provider is selected, so the default path captures no host fingerprints (see +/// [`BuiltinDeviceProvider`] for the host-neutral default). A +/// selected-but-unknown provider is rejected at startup by +/// [`DeviceConfig::validate_provider_selection`](crate::settings::DeviceConfig::validate_provider_selection), +/// so this falls back to the built-in provider for that case. +#[must_use] +pub fn build_device_provider( + settings: &Settings, + build_fastly: impl FnOnce() -> Box, +) -> Box { + match settings.device.provider_key() { + "fastly" => build_fastly(), + _ => Box::new(BuiltinDeviceProvider::new()), + } +} + /// Device is a desktop (confirmed via UA platform token). const MOBILE_DESKTOP: u8 = 0; /// Device is a mobile (confirmed via UA mobile token). @@ -146,6 +246,53 @@ fn parse_platform_class(ua: &str) -> Option { None } +/// Decides whether a request looks like a real browser from the User-Agent +/// alone, with no TLS or HTTP/2 evidence. +/// +/// A real browser sends the `Mozilla/` token every major engine still emits and +/// a recognizable platform string (so `platform_class` is present), and is not +/// an obvious bot or command-line client. Raw HTTP clients (curl, Python +/// requests, Go net/http) carry no platform token, so they fail the +/// `platform_class` check; declared crawlers are caught by [`looks_like_bot_ua`]. +/// +/// # Threat model +/// +/// This is the default, host-neutral gate. It filters obvious non-browser +/// traffic but does not resist a bot that forges a complete browser +/// User-Agent. The opt-in Fastly device provider strengthens the gate with the +/// TLS/H2 fingerprints for deployments that need it. +#[must_use] +fn looks_like_browser_from_ua(ua: &str, platform_class: Option<&str>) -> bool { + platform_class.is_some() && ua.contains("Mozilla/") && !looks_like_bot_ua(ua) +} + +/// Returns `true` when the User-Agent declares a known bot, crawler, or +/// non-browser HTTP client. +/// +/// Matches common self-identifying markers case-insensitively. The `bot` marker +/// covers `Googlebot`, `bingbot`, and similar; the library markers cover HTTP +/// clients that set a recognizable platform token. +#[must_use] +fn looks_like_bot_ua(ua: &str) -> bool { + const BOT_MARKERS: &[&str] = &[ + "bot", + "crawl", + "spider", + "slurp", + "curl", + "wget", + "python-requests", + "go-http-client", + "okhttp", + "java/", + "headlesschrome", + "phantomjs", + "scrapy", + ]; + let lower = ua.to_ascii_lowercase(); + BOT_MARKERS.iter().any(|marker| lower.contains(marker)) +} + /// Extracts Section 1 from a full JA4 fingerprint. /// /// JA4 format: `section1_section2_section3` separated by underscores. @@ -230,6 +377,7 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> #[cfg(test)] mod tests { use super::*; + use crate::evidence::OwnedRequestInfo; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ @@ -523,7 +671,7 @@ mod tests { Some("1:65536;2:0;4:6291456;6:262144"), ); assert!( - signals.looks_like_browser(), + signals.looks_like_browser, "Chrome/Mac should look like a browser" ); } @@ -537,7 +685,7 @@ mod tests { Some("99:99;88:88"), ); assert!( - signals.looks_like_browser(), + signals.looks_like_browser, "unknown fingerprint with valid JA4 + platform should pass" ); assert_eq!(signals.known_browser, None, "should not match allowlist"); @@ -547,7 +695,7 @@ mod tests { fn looks_like_browser_rejects_bot() { let signals = DeviceSignals::derive(BOT_UA, None, None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "bot with no JA4 and no platform should be rejected" ); } @@ -557,7 +705,7 @@ mod tests { // Real UA but no TLS fingerprint (e.g. HTTP/1.1 or missing SDK support) let signals = DeviceSignals::derive(CHROME_MAC_UA, None, Some("1:65536")); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "missing JA4 should be rejected even with valid UA" ); } @@ -567,8 +715,138 @@ mod tests { // Has JA4 but unrecognizable UA let signals = DeviceSignals::derive(BOT_UA, Some("t13d1516h2_abc_def"), None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "unrecognizable UA should be rejected even with JA4" ); } + + #[test] + fn derive_ua_only_accepts_real_browsers_without_fingerprints() { + for ua in [ + CHROME_MAC_UA, + SAFARI_IOS_UA, + FIREFOX_MAC_UA, + CHROME_ANDROID_UA, + CHROME_WINDOWS_UA, + ] { + let signals = DeviceSignals::derive_ua_only(ua); + assert!( + signals.looks_like_browser, + "a real browser UA should pass the UA-only gate: {ua}" + ); + assert!( + signals.ja4_class.is_none() && signals.h2_fp_hash.is_none(), + "the UA-only path must not record any TLS/H2 evidence" + ); + } + } + + #[test] + fn derive_ua_only_rejects_bots_and_http_clients() { + // Declared crawlers and CLI/library clients must not pass the gate. + for ua in [ + BOT_UA, + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + "curl/8.4.0", + "python-requests/2.31.0", + "Go-http-client/2.0", + "", + ] { + assert!( + !DeviceSignals::derive_ua_only(ua).looks_like_browser, + "a non-browser client should fail the UA-only gate: {ua:?}" + ); + } + } + + #[test] + fn derive_ua_only_rejects_a_browser_ua_that_declares_a_bot() { + // Newer crawlers send a full browser UA with a platform token; the bot + // marker must still reject them. + let googlebot_mobile = "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36 \ + (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; + assert!( + !DeviceSignals::derive_ua_only(googlebot_mobile).looks_like_browser, + "a browser-shaped UA declaring Googlebot should be rejected" + ); + } + + #[test] + fn builtin_device_provider_is_ua_only() { + let provider = BuiltinDeviceProvider::new(); + assert_eq!(provider.id(), "builtin"); + + // The built-in provider classifies from the User-Agent in the request + // info passed to `detect` alone, recording no host fingerprint. + let request_info = request_info_with_ua(CHROME_MAC_UA); + let signals = provider.detect(&request_info); + assert_eq!( + signals, + DeviceSignals::derive_ua_only(CHROME_MAC_UA), + "the built-in provider should classify from the User-Agent only" + ); + assert!( + signals.ja4_class.is_none(), + "the built-in provider must not record a JA4 class" + ); + } + + /// Builds request info carrying the given User-Agent, for provider tests. + fn request_info_with_ua(user_agent: &str) -> OwnedRequestInfo { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_str(user_agent) + .expect("should build a valid User-Agent header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + /// A stand-in for the host-specific provider the adapter injects, so the + /// selection logic can be tested in core without the Fastly provider crate. + struct StubFastlyProvider; + + impl DeviceProvider for StubFastlyProvider { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, _request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only("") + } + } + + #[test] + fn builtin_device_provider_requires_no_permissions() { + assert!( + BuiltinDeviceProvider::new() + .required_permissions() + .is_empty(), + "the built-in User-Agent-only device provider requires no permissions" + ); + } + + #[test] + fn build_device_provider_defaults_to_builtin_and_selects_injected() { + // The default selector returns the built-in provider, ignoring the + // injected candidate. + let settings = crate::settings::Settings::default(); + let default = build_device_provider(&settings, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!(default.id(), "builtin", "no selector should be UA-only"); + + // The `fastly` selector returns the provider the adapter's factory builds. + let mut fastly = crate::settings::Settings::default(); + fastly.device.provider = Some("fastly".to_owned()); + let selected = build_device_provider(&fastly, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!( + selected.id(), + "fastly", + "the fastly selector should use the injected provider" + ); + } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 2c10d2df0..47b54b525 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,9 +8,9 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; -use super::consent::{ec_consent_granted, ec_consent_withdrawn}; use crate::settings::Settings; +use super::consent::ec_storage_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; @@ -29,12 +29,16 @@ const EC_RESPONSE_HEADERS: &[&str] = &[ /// Finalizes EC response behavior for all routes. /// -/// Applies withdrawal handling, last-seen updates, cookie reconciliation, -/// Prebid EID ingestion, and cookie writes for new EC generation. +/// Applies the resolved permission state, last-seen updates, cookie +/// reconciliation, Prebid EID ingestion, and cookie writes for new EC generation. /// -/// On consent withdrawal, the browser response clears the EC cookie -/// immediately and the EC identity-graph KV tombstone is the authoritative -/// revocation marker. There is no separate consent KV store to clean up. +/// When the request carries an explicit withdrawal signal (a storage opt-out or +/// a TCF record refusing storage) and the client presented a cookie, the browser +/// response clears the EC cookie immediately and the EC identity-graph KV +/// tombstone is the authoritative revocation marker. A request that is merely +/// not permitted (pre-consent or fail-closed) strips EC response headers but +/// leaves an already-issued cookie intact. There is no separate consent KV +/// store to clean up. /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. @@ -47,19 +51,27 @@ pub fn ec_finalize_response( sharedid_cookie: Option<&str>, response: &mut Response, ) { - let consent_allows_ec = ec_consent_granted(ec_context.consent()); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); - - if !consent_allows_ec { - // Always strip EC-specific response headers when consent is not - // currently usable for this request. This covers both explicit - // revocation and fail-closed cases such as missing geo or undecodable - // consent input. + // Apply any response headers the active provider asked for during + // generation (for example to request more client evidence). This is empty + // unless a provider produced headers, so it is safe on every path. + for (name, value) in ec_context.response_headers() { + response.headers_mut().insert(name, value.clone()); + } + + let ec_permitted = ec_context.ec_allowed(); + + if !ec_permitted { + // Always strip EC-specific response headers when EC is not permitted for + // this request, covering both an explicit withdrawal and fail-closed + // cases such as missing geo or undecodable consent input. clear_ec_headers_on_response(response, Some(registry)); // Only expire the browser cookie and tombstone the identity-graph row - // when the request carries an explicit withdrawal signal. - if consent_withdrawn && ec_context.cookie_was_present() { + // when the request carries an explicit withdrawal signal. A pre-consent + // or fail-closed state (the permission is simply not set) strips headers + // but must not destroy an already-issued identifier, or a returning user + // would be permanently withdrawn before they ever get to consent. + if ec_storage_withdrawn(ec_context.consent()) && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. @@ -82,8 +94,8 @@ pub fn ec_finalize_response( return; } - // Returning user: consent is granted and EC came from request. - if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { + // Returning user: EC is permitted and came from the request. + if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); } @@ -219,6 +231,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, jurisdiction: Jurisdiction, + ec_allowed: bool, ) -> EcContext { let consent = ConsentContext { jurisdiction, @@ -232,6 +245,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -241,6 +255,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> EcContext { EcContext::new_for_test_with_cookie( ec_value.map(str::to_owned), @@ -248,6 +263,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -275,7 +291,14 @@ mod tests { #[test] fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); - let ec_context = make_context(None, Some(&cookie_ec), true, false, Jurisdiction::Unknown); + let ec_context = make_context( + None, + Some(&cookie_ec), + true, + false, + Jurisdiction::Unknown, + false, + ); let ids = withdrawal_ec_ids(&ec_context); @@ -295,6 +318,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -313,6 +337,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -331,6 +356,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -402,7 +428,7 @@ mod tests { ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -459,6 +485,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -493,6 +520,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -527,6 +555,7 @@ mod tests { false, true, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -554,7 +583,7 @@ mod tests { #[test] fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); - let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); + let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown, false); let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); @@ -579,7 +608,12 @@ mod tests { } #[test] - fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { + fn finalize_not_permitted_without_withdrawal_keeps_cookie() { + // When EC is not permitted (here a fail-closed unknown jurisdiction with + // no geo) but the request carries no explicit withdrawal signal, the + // response strips EC headers yet must leave an already-issued cookie + // intact. A pre-consent or transient fail-closed request must not + // permanently withdraw a returning user before they get to consent. let settings = create_test_settings(); let ec_id = sample_ec_id("unk001"); let ec_context = make_context( @@ -588,6 +622,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", &ec_id); @@ -606,15 +641,78 @@ mod tests { assert!( get_header(&response, "x-ts-ec").is_none(), - "should strip EC header when consent cannot be verified" + "should strip EC header when EC is not permitted" ); assert!( get_header(&response, "x-ts-eids").is_none(), - "should strip EID header when consent cannot be verified" + "should strip EID header when EC is not permitted" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "a not-permitted request without a withdrawal signal should keep the cookie" + ); + } + + #[test] + fn set_ec_cookie_on_response_writes_the_ts_ec_cookie() { + // The positive case: when an EC value is present, the finalize path + // writes the ts-ec cookie to the browser, carrying the EC id. + let settings = create_test_settings(); + let ec_id = sample_ec_id("setck1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + true, + ); + let mut response = empty_response(); + + set_ec_cookie_on_response(&settings, &ec_context, &mut response); + + let set_cookie = + get_header_str(&response, "set-cookie").expect("an EC value should write a Set-Cookie"); + assert!( + set_cookie.contains("ts-ec=") && set_cookie.contains(&ec_id), + "should write the ts-ec cookie carrying the EC id, got: {set_cookie}" ); + } + + #[test] + fn closed_permission_gate_writes_no_ec_cookie() { + // The gate: with the permission gate closed (ec_allowed = false), no + // ts-ec cookie is written, even when an EC value and a generated flag are + // present. The permission model is what suppresses the cookie. + let settings = create_test_settings(); + let ec_id = sample_ec_id("gated1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + false, + ); + let mut response = empty_response(); + + // Pass a KV graph so the missing-graph guard cannot be the reason the + // cookie is suppressed; the closed gate must be doing the work. + let kv = KvIdentityGraph::failing("test_store"); + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + Some(&kv), + &test_registry, + None, + None, + &mut response, + ); + assert!( get_header(&response, "set-cookie").is_none(), - "should not expire the cookie without an explicit withdrawal signal" + "a closed permission gate must not write a ts-ec cookie" ); } } diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index af40c3ff4..0afc3b13d 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -11,7 +11,6 @@ use rand::Rng; use sha2::Sha256; use crate::error::TrustedServerError; -use crate::settings::Settings; type HmacSha256 = Hmac; @@ -81,19 +80,39 @@ fn generate_random_suffix(length: usize) -> String { /// /// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails pub fn generate_ec_id( - settings: &Settings, + passphrase: &str, client_ip: &str, ) -> Result> { - let mut mac = HmacSha256::new_from_slice(settings.ec.passphrase.expose().as_bytes()) - .change_context(TrustedServerError::EdgeCookie { + generate_hmac_ec_id(passphrase, &[client_ip]) +} + +/// Mints an Edge Cookie identifier as HMAC-SHA256 over the given parts plus a +/// random suffix, in the `{64hex}.{6alnum}` format. +/// +/// The parts are joined with a unit separator (`\u{1f}`), which cannot appear in +/// a client IP, User-Agent, JA4, or HTTP/2 fingerprint, so distinct part lists +/// cannot collide. A provider that derives identity from several request signals +/// (for example a Fastly provider over JA4, H2, IP, and UA) passes them as +/// separate parts. Each part must be pre-normalized by the caller. +/// +/// # Errors +/// +/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +pub fn generate_hmac_ec_id( + passphrase: &str, + parts: &[&str], +) -> Result> { + let mut mac = HmacSha256::new_from_slice(passphrase.as_bytes()).change_context( + TrustedServerError::EdgeCookie { message: "Failed to create HMAC instance".to_string(), - })?; - mac.update(client_ip.as_bytes()); + }, + )?; + // A unit separator cannot occur in any part, so distinct lists never collide. + mac.update(parts.join("\u{1f}").as_bytes()); let hmac_hash = hex::encode(mac.finalize().into_bytes()); - // Append random 6-character alphanumeric suffix for additional uniqueness. - let random_suffix = generate_random_suffix(6); - let ec_id = format!("{hmac_hash}.{random_suffix}"); + // Append a random 6-character alphanumeric suffix for additional uniqueness. + let ec_id = format!("{hmac_hash}.{}", generate_random_suffix(6)); log::trace!("Generated fresh EC ID: {}", super::log_id(&ec_id)); @@ -175,7 +194,39 @@ mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; - use crate::test_support::tests::create_test_settings; + const TEST_PASSPHRASE: &str = "test-secret-key-32-bytes-minimum"; + + #[test] + fn generate_hmac_ec_id_is_stable_per_parts_and_collision_resistant() { + // The 64-char hex prefix is HMAC over the parts and is stable for the + // same parts; the random suffix varies, so compare prefixes only. + let prefix = |parts: &[&str]| { + generate_hmac_ec_id(TEST_PASSPHRASE, parts) + .expect("should generate") + .split('.') + .next() + .expect("should have a prefix") + .to_owned() + }; + + assert_eq!( + prefix(&["a", "b"]), + prefix(&["a", "b"]), + "the same parts should yield the same stable prefix" + ); + assert_ne!( + prefix(&["a", "b"]), + prefix(&["a", "c"]), + "different parts should yield a different prefix" + ); + // The unit separator prevents a join collision: ["a", "b"] must not hash + // the same as ["ab"]. + assert_ne!( + prefix(&["a", "b"]), + prefix(&["ab"]), + "the separator should prevent ['a','b'] colliding with ['ab']" + ); + } #[test] fn normalize_ipv4_unchanged() { @@ -215,8 +266,7 @@ mod tests { #[test] fn generate_produces_valid_format() { - let settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, "192.168.1.1").expect("should generate EC ID"); + let ec_id = generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate EC ID"); assert!( is_valid_ec_id(&ec_id), "should match EC ID format: {{64hex}}.{{6alnum}}, got: {ec_id}" @@ -225,10 +275,10 @@ mod tests { #[test] fn generate_same_ip_produces_consistent_hash_prefix() { - let settings = create_test_settings(); - let first = generate_ec_id(&settings, "192.168.1.1").expect("should generate first EC ID"); + let first = + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate first EC ID"); let second = - generate_ec_id(&settings, "192.168.1.1").expect("should generate second EC ID"); + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate second EC ID"); assert_eq!( ec_hash(&first), diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 1a1694247..8463ee945 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -10,7 +10,6 @@ use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; -use super::consent::ec_consent_granted; use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use crate::settings::Settings; @@ -62,7 +61,7 @@ pub fn handle_identify( ); }; - if !ec_consent_granted(ec_context.consent()) { + if !ec_context.ec_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), @@ -332,7 +331,6 @@ fn apply_cors_headers(response: &mut Response, origin: &str) { #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; use crate::ec::registry::PartnerRegistry; use crate::redacted::Redacted; @@ -352,13 +350,12 @@ mod tests { ); } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { - jurisdiction, source: ConsentSource::Cookie, ..ConsentContext::default() }; - EcContext::new_for_test(ec_value.map(str::to_owned), consent) + EcContext::new_for_test_gated(ec_value.map(str::to_owned), consent, ec_allowed) } fn make_test_partner(source_domain: &str, api_token: &str) -> EcPartner { @@ -472,7 +469,7 @@ mod tests { .uri("https://edge.test-publisher.com/identify") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -514,7 +511,7 @@ mod tests { .header("authorization", "Bearer wrong-token") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -539,7 +536,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::Unknown, None); + let ec_context = make_ec_context(false, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct denied response"); @@ -573,7 +570,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response"); @@ -599,7 +596,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct degraded identify response"); @@ -652,7 +649,7 @@ mod tests { .header("origin", "https://evil.example") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct forbidden response"); @@ -678,7 +675,7 @@ mod tests { .header("origin", "https://www.test-publisher.com") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response with CORS headers"); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 408ea9b32..a7f1eb9d2 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -15,7 +15,7 @@ //! //! - auth (private) — shared Bearer-token authentication helpers //! - [`generation`] — HMAC-based ID generation, IP normalization, format helpers -//! - [`consent`] — EC-specific consent gating wrapper +//! - [`consent`]: EC-specific permission gating, with consent as one input //! - [`cookies`] — `Set-Cookie` header creation and expiration helpers //! - [`kv`] — KV Store identity graph operations (CAS, tombstones, debounce) //! - [`kv_backend`] — Platform-neutral KV primitives implemented by adapters @@ -44,9 +44,11 @@ pub mod kv_backend; pub mod kv_types; pub mod partner; pub mod prebid_eids; +pub mod provider; pub mod pull_sync; pub mod rate_limiter; pub mod registry; +pub mod resolve; /// Truncates an EC ID for safe inclusion in log messages. /// @@ -59,6 +61,8 @@ pub fn log_id(ec_id: &str) -> String { format!("{prefix}\u{2026}") } +use std::sync::Arc; + use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -69,10 +73,13 @@ use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; +use crate::evidence::{BorrowedRequestInfo, HostSignals}; use crate::geo::GeoInfo; +use crate::permissions::PermissionState; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; +use provider::{build_provider, EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -151,6 +158,14 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, + /// Whether the configured Edge Cookie provider's required permissions are + /// set for this request. Resolved once at construction through the + /// permission model and read via [`ec_allowed`](Self::ec_allowed). + ec_allowed: bool, + /// The permissions resolved for this request: the country/region baseline + /// augmented by the session's signals. Assembled once at construction and + /// read via [`permissions`](Self::permissions). + permissions: PermissionState, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -160,6 +175,15 @@ pub struct EcContext { /// Set via [`EcContext::set_device_signals`] before /// [`EcContext::generate_if_needed`] is called. device_signals: Option, + /// The host-signal service for this request, when the host supplies one + /// (the Fastly adapter registers the TLS/HTTP-2 fingerprints). `None` on a + /// host that exposes none. Injected into a provider that needs it when the + /// provider is built. + host_signals: Option>, + /// Response headers a provider asked to set, captured during + /// [`EcContext::generate_if_needed`] and applied to the response by EC + /// finalization. Empty for providers that set no headers. + response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } impl EcContext { @@ -222,11 +246,27 @@ impl EcContext { kv_store: None, }); + // Assemble the permission state once, here, through the permission + // model, building the country/region baseline augmented by the session's + // signals. Downstream consumers read the stored result via + // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than + // re-deriving it. + let permissions = consent::assemble_permissions(settings, &consent, geo_info); + // Build the selected provider once, injecting the request info and any + // host signals the host supplies, to read its required permissions. A + // provider that needs a service the host did not supply fails to build + // here, which stops the request. + let host_signals = services.host_signals(); + // The provider is built here only to read its required permissions, which + // need no request data, so nothing is cloned from the request. + let ec_allowed = build_provider(&settings.ec, host_signals.clone())? + .is_none_or(|provider| permissions.all_set(provider.required_permissions())); + log::info!( - "EC context: present={}, cookie_present={}, consent_allowed={}, jurisdiction={}", + "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", ec_was_present, parsed.cookie_ec.is_some(), - consent::ec_consent_granted(&consent), + ec_allowed, consent.jurisdiction, ); @@ -236,9 +276,13 @@ impl EcContext { ec_was_present, ec_generated: false, consent, + ec_allowed, + permissions, client_ip, geo_info: geo_info.cloned(), device_signals: None, + host_signals, + response_headers: Vec::new(), }) } @@ -264,22 +308,77 @@ impl EcContext { return Ok(()); } - if !consent::ec_consent_granted(&self.consent) { + if !self.ec_allowed { log::info!( - "EC generation skipped: consent not granted (jurisdiction={})", + "EC generation skipped: required permissions not set (jurisdiction={})", self.consent.jurisdiction, ); return Ok(()); } - let client_ip = self.client_ip.as_deref().ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { + // EC generation needs the client IP; fail early if it is unavailable. The + // provider reads it borrowed at generate time (see + // [`generate_with_provider`]), so nothing is cloned here. + if self.client_ip.is_none() { + return Err(Report::new(TrustedServerError::EdgeCookie { message: "Client IP required for EC generation but unavailable".to_owned(), - }) - })?; + })); + } + let Some(ec_provider) = build_provider(&settings.ec, self.host_signals.clone())? else { + log::info!("EC generation skipped: no Edge Cookie provider configured"); + return Ok(()); + }; + + self.generate_with_provider(ec_provider.as_ref(), settings, kv) + } - let ec_id = generation::generate_ec_id(settings, client_ip)?; - log::info!("Generated new EC ID: {}", log_id(&ec_id)); + /// Derives and commits an EC identifier using a specific provider. + /// + /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the + /// provider is supplied explicitly: the configured path builds it from + /// settings, and tests pass one in to observe the [`IdentityInput`] a + /// provider receives. This path passes no header snapshot (the built-ins + /// read only the client IP); a provider that needs request headers reads + /// them through [`RequestInfo`](crate::evidence::RequestInfo) where the + /// caller supplies them. The skip guards (existing EC, permission gate) + /// stay in [`generate_if_needed`](Self::generate_if_needed). + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when the client IP is + /// unavailable, the provider fails to derive an identifier, or persisting a + /// generated identifier to the KV identity graph fails. + fn generate_with_provider( + &mut self, + ec_provider: &dyn EdgeCookieProvider, + settings: &Settings, + kv: Option<&KvIdentityGraph>, + ) -> Result<(), Report> { + let input = IdentityInput { + permissions: Some(&self.permissions), + consent: Some(&self.consent), + }; + // The provider reads only the client IP on this path; pass it borrowed + // with no header snapshot, so no request data is cloned. + let request_info = + BorrowedRequestInfo::new(self.client_ip.as_deref().unwrap_or_default(), None); + let generated: GeneratedEdgeCookie = ec_provider.generate(&request_info, &input)?; + // Capture any response headers the provider asked for, even when it + // produced no identifier (for example while it still needs more client + // evidence). EC finalization applies them to the response. + self.response_headers = generated.response_headers; + let Some(ec_id) = generated.id else { + log::info!( + "EC generation produced no identifier (provider={}); proceeding without an EC", + ec_provider.id(), + ); + return Ok(()); + }; + log::info!( + "Generated new EC ID (provider={}): {}", + ec_provider.id(), + log_id(&ec_id), + ); self.ec_value = Some(ec_id); self.ec_generated = true; @@ -364,6 +463,14 @@ impl EcContext { self.device_signals = Some(signals); } + /// Returns the response headers a provider asked to set during + /// [`generate_if_needed`](Self::generate_if_needed). Empty unless a provider + /// produced any. + #[must_use] + pub fn response_headers(&self) -> &[(http::HeaderName, http::HeaderValue)] { + &self.response_headers + } + /// Returns the device signals, if set. #[must_use] pub fn device_signals(&self) -> Option<&DeviceSignals> { @@ -376,16 +483,40 @@ impl EcContext { self.client_ip.as_deref() } + /// Returns the host-computed client fingerprints captured for this request, + /// when the host supplies them. + /// + /// The resolve path rebuilds the provider with the same injected services + /// as the organic path, so it reads the host signals captured here rather + /// than re-deriving them. + pub(crate) fn host_signals(&self) -> Option> { + self.host_signals.clone() + } + /// Returns the pre-routing geo data, if available. #[must_use] pub fn geo_info(&self) -> Option<&GeoInfo> { self.geo_info.as_ref() } - /// Returns whether EC creation is permitted by consent for this request. + /// Returns whether the configured Edge Cookie provider's required + /// permissions are set for this request. + /// + /// Resolved once at construction through the permission model (see + /// [`consent::ec_permission_granted`]). #[must_use] pub fn ec_allowed(&self) -> bool { - consent::ec_consent_granted(&self.consent) + self.ec_allowed + } + + /// Returns the permissions resolved for this request. + /// + /// Assembled once at construction, the country/region baseline augmented by + /// the session's signals. The core gates provider execution on these, and a + /// consumer may read them for its own logic. + #[must_use] + pub fn permissions(&self) -> &PermissionState { + &self.permissions } /// Returns the existing EC cookie value for revocation handling. @@ -399,12 +530,17 @@ impl EcContext { } /// Returns `true` when the request carried a cookie EC and the selected - /// active EC differs from that cookie value. + /// active EC denotes a different identity than the cookie value. + /// + /// The equality test is delegated to the [`EdgeCookieProvider`], because EC + /// identifiers are not assumed comparable by natural string equality: two + /// values may be different wrappers of the same payload, which only the + /// provider knows how to compare. #[must_use] - pub fn cookie_differs_from_active_ec(&self) -> bool { + pub fn cookie_differs_from_active_ec(&self, provider: &dyn EdgeCookieProvider) -> bool { matches!( (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), - (Some(cookie), Some(active)) if cookie != active + (Some(cookie), Some(active)) if !provider.keys_equal(cookie, active) ) } @@ -414,19 +550,41 @@ impl EcContext { self.ec_value.as_deref().map(generation::ec_hash) } - /// Creates a test-only `EcContext` with explicit field values. + /// Creates a test-only `EcContext` with the permission gate open. + /// + /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs + /// the gate closed. #[cfg(test)] #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { + Self::new_for_test_gated(ec_value, consent, true) + } + + /// Creates a test-only `EcContext` with an explicit permission gate. + /// + /// `ec_allowed` stands in for the permission decision the production path + /// resolves at construction, so a test can exercise the gate-open and + /// gate-closed branches directly. + #[cfg(test)] + #[must_use] + pub fn new_for_test_gated( + ec_value: Option, + consent: ConsentContext, + ec_allowed: bool, + ) -> Self { Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, + permissions: PermissionState::default(), client_ip: None, geo_info: None, device_signals: None, + host_signals: None, + response_headers: Vec::new(), } } @@ -444,9 +602,13 @@ impl EcContext { ec_value, ec_generated: false, consent, + ec_allowed: true, + permissions: PermissionState::default(), client_ip, geo_info: None, device_signals: None, + host_signals: None, + response_headers: Vec::new(), } } @@ -460,6 +622,7 @@ impl EcContext { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> Self { Self { ec_value, @@ -467,9 +630,13 @@ impl EcContext { ec_was_present, ec_generated, consent, + ec_allowed, + permissions: PermissionState::default(), client_ip: None, geo_info: None, device_signals: None, + host_signals: None, + response_headers: Vec::new(), } } } @@ -493,6 +660,7 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -511,6 +679,127 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } + /// A test provider that compares identifiers by the payload after a `:`, + /// modeling an envelope whose wrapper can differ for the same identity. + struct WrapperInsensitiveProvider; + + impl EdgeCookieProvider for WrapperInsensitiveProvider { + fn id(&self) -> &'static str { + "wrapper-insensitive" + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn keys_equal(&self, left: &str, right: &str) -> bool { + fn payload(value: &str) -> &str { + value.split_once(':').map_or(value, |(_, payload)| payload) + } + payload(left) == payload(right) + } + } + + /// A test provider that does not override `keys_equal`, so it uses the + /// default natural string equality. + struct NaturalProvider; + + impl EdgeCookieProvider for NaturalProvider { + fn id(&self) -> &'static str { + "natural" + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn cookie_differs_from_active_ec_delegates_to_the_provider() { + // The cookie and the active EC are different wrappers of one payload. + let context = EcContext::new_for_test_with_cookie( + Some("wrapper-active:shared-payload".to_owned()), + Some("wrapper-cookie:shared-payload".to_owned()), + true, + false, + ConsentContext::default(), + true, + ); + + assert!( + !context.cookie_differs_from_active_ec(&WrapperInsensitiveProvider), + "a payload-aware provider should treat different wrappers as the same identity" + ); + assert!( + context.cookie_differs_from_active_ec(&NaturalProvider), + "natural equality should treat different wrappers as different" + ); + } + + /// A provider that records the `Cookie` header from the request info passed + /// to `generate`, so a test can prove request cookies reach a provider (a + /// client that stores values in cookies relies on this). + struct CookieCapturingProvider { + seen_cookie: std::sync::Mutex>, + } + + impl EdgeCookieProvider for CookieCapturingProvider { + fn id(&self) -> &'static str { + "cookie-capturing" + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let cookie = request_info.header("cookie").map(ToOwned::to_owned); + *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn a_provider_reads_request_cookies_from_the_request_info() { + // RequestInfo contract: a provider given request info that carries + // headers can read request cookies through it (a client that stores + // values in cookies relies on this). The organic generate path passes + // no header snapshot; a caller that has headers supplies them. + let mut headers = http::HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should build a valid cookie header"), + ); + let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); + let provider = CookieCapturingProvider { + seen_cookie: std::sync::Mutex::new(None), + }; + + provider + .generate(&request_info, &IdentityInput::default()) + .expect("generation should succeed"); + + assert_eq!( + provider + .seen_cookie + .lock() + .expect("should lock seen cookie") + .as_deref(), + Some("client-id=abc123; ts-ec=xyz"), + "the provider should read the request cookies from the request info" + ); + } + #[test] fn read_from_request_ignores_header_ec() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs new file mode 100644 index 000000000..405d66516 --- /dev/null +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -0,0 +1,622 @@ +//! Edge Cookie identity providers. +//! +//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. Providers are +//! wired by dependency injection: a provider's constructor takes the services it +//! needs (for example [`RequestInfo`] for the client IP, or [`HostSignals`] for +//! the TLS/HTTP-2 fingerprints) as `Arc`, and the composition root +//! (the adapter, through [`build_provider`]) supplies instances per request. A +//! provider that needs a service the host does not supply cannot be built, so +//! the request stops rather than silently degrading. +//! +//! The provider is selected by configuration, with no default. [`HmacProvider`] +//! is the built-in server-side implementation that derives the identifier from +//! the client IP using HMAC, the behavior Trusted Server has always shipped. + +use std::sync::Arc; + +use error_stack::Report; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::{HostSignals, RequestInfo}; +use crate::permissions::{Permission, PermissionSet, PermissionState}; +use crate::redacted::Redacted; +use crate::settings::Ec; + +use super::generation; + +/// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. +/// +/// Request data (client IP, User-Agent, headers, host signals) reaches a +/// provider through the services injected into its constructor, not through this +/// struct. This carries only the per-request gating context a provider may read +/// for behavior beyond gating. The gate has already confirmed the provider's +/// required permissions are set before `generate` is called. +#[derive(Default)] +pub struct IdentityInput<'a> { + /// The permissions resolved for this request, when the calling path carries + /// them. A provider reads this only for behavior beyond gating. The main + /// organic path supplies them; the publisher path passes `None`. + pub permissions: Option<&'a PermissionState>, + + /// The request's consent context, when available, for provider-specific + /// logic. The core gates on permissions, not consent, so a provider reads + /// this only to forward or record consent. [`HmacProvider`] ignores it. + pub consent: Option<&'a ConsentContext>, +} + +/// Inputs available to [`EdgeCookieProvider::resolve_from_client`]. +/// +/// Carries the value a client produced and posted to the Edge Cookie resolve +/// endpoint, alongside the same gating context as [`IdentityInput`]. Request data +/// reaches the provider through its injected services. Unlike trusted edge-derived +/// data, [`payload`](Self::payload) arrives from the browser, so an +/// implementation must verify it before deriving an identifier from it. +pub struct ClientResolveInput<'a> { + /// The raw body the client posted to the resolve endpoint. For a vendor + /// provider this is its own JSON envelope; for the built-in + /// [`ClientFixedProvider`] demo it is the fixed known word the page script + /// posts. + pub payload: &'a [u8], + + /// The permissions resolved for the resolve request. The endpoint has + /// already confirmed the provider's required permissions are set, so a + /// provider reads this only for behavior beyond gating. + pub permissions: Option<&'a PermissionState>, + + /// The resolve request's consent context, for provider-specific logic. The + /// core gates on permissions, not consent. + pub consent: Option<&'a ConsentContext>, +} + +/// The outcome of [`EdgeCookieProvider::generate`]. +/// +/// Carries the derived identifier, if any, and any response headers the provider +/// needs set on the outbound response. +#[derive(Debug, Default)] +pub struct GeneratedEdgeCookie { + /// The derived Edge Cookie identifier, or `None` when the provider produced + /// none for this request. + pub id: Option, + + /// Response headers the provider needs set on the outbound response, for + /// example to request additional client evidence on later requests. Empty + /// for providers that set no headers, such as [`HmacProvider`]. + pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, +} + +/// A strategy for deriving an Edge Cookie identifier. +/// +/// Implementations are selected by configuration and come in two types, which +/// reach the same outcome (a `ts-ec` cookie) by different routes: +/// +/// - **Server-side** (for example [`HmacProvider`]): derives the identifier at +/// the edge in [`generate`](Self::generate), and the page response sets the +/// cookie. Nothing client-side is involved. +/// - **Client-side** (for example [`ClientFixedProvider`]): defers in +/// [`generate`](Self::generate) (returns `id: None`), runs its own JavaScript +/// in the browser, and mints from the value the page posts back in +/// [`resolve_from_client`](Self::resolve_from_client), whose response sets the +/// cookie. +/// +/// A provider returns `Ok(None)` from [`generate`](Self::generate) when it +/// cannot derive an identifier at the edge, so the request proceeds without an +/// Edge Cookie rather than failing. +pub trait EdgeCookieProvider: Send + Sync { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// Derives an Edge Cookie identifier from the provider's injected services + /// and the request's gating context. + /// + /// A server-side provider mints here. A client-side provider defers here + /// (returns `id: None`) and mints later in + /// [`resolve_from_client`](Self::resolve_from_client) from the value the page + /// posts back. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + + /// Returns whether two identifiers produced by this provider denote the same + /// identity. + /// + /// Edge Cookie identifiers must not be assumed comparable by natural string + /// equality. A provider whose identifiers can carry the same payload in + /// different wrappers (for example a signed envelope that is re-issued with a + /// new timestamp or signature) overrides this to compare by payload, so the + /// system asks the provider rather than comparing the raw strings. + /// + /// The default compares the values for byte equality, which is correct for + /// providers whose identifiers are canonical, such as [`HmacProvider`]. + fn keys_equal(&self, left: &str, right: &str) -> bool { + left == right + } + + /// The permissions this provider's data use requires. + /// + /// Trusted Server executes the provider only when every permission returned + /// here is set. The default is empty, so a vendor-neutral provider requires + /// no permission. A provider that stores identity on the device, or shares it + /// onward, declares the matching permission so the request's country and + /// signal rules can gate it. + fn required_permissions(&self) -> PermissionSet { + PermissionSet::none() + } + + /// Derives an Edge Cookie identifier from a value the client produced and + /// posted to the resolve endpoint (`POST /_ts/api/v1/ec/resolve`). + /// + /// This is the client-side counterpart to [`generate`](Self::generate). A + /// provider that cannot derive an identifier at the edge defers from + /// `generate` (returning `id: None`, optionally with response headers that + /// trigger client-side work), and the page posts its result back here. The + /// payload arrives from the browser, so an implementation MUST verify it + /// (for example checking a signature) before trusting it. The default + /// returns no identifier, so a provider that mints entirely server-side + /// (such as [`HmacProvider`]) need not implement it. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when processing the payload + /// fails. A payload that is merely unverified or absent yields `id: None` + /// rather than an error, so the request proceeds without an Edge Cookie. + fn resolve_from_client( + &self, + _input: &ClientResolveInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } +} + +/// The built-in HMAC Edge Cookie provider. +/// +/// Derives the identifier from the client IP (read from the [`RequestInfo`] +/// passed at call time) and the configured passphrase via +/// [`generation::generate_ec_id`]. +#[derive(Debug, Clone)] +pub struct HmacProvider { + passphrase: Redacted, +} + +impl HmacProvider { + /// Creates an HMAC provider with the given passphrase. + #[must_use] + pub fn new(passphrase: Redacted) -> Self { + Self { passphrase } + } +} + +impl EdgeCookieProvider for HmacProvider { + fn id(&self) -> &'static str { + "hmac" + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let id = generation::generate_ec_id(self.passphrase.expose(), request_info.client_ip())?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } + + fn required_permissions(&self) -> PermissionSet { + // The HMAC provider writes the Edge Cookie to the device, so it requires + // permission to store on the device (TCF Purpose 1). Whether that needs a + // signal is decided by the country rules, not by the provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + +/// The built-in host-signal Edge Cookie provider. +/// +/// Derives the identifier from the host fingerprints (TLS JA4 and HTTP/2, read +/// from the injected [`HostSignals`]) plus the client IP (from [`RequestInfo`]), +/// keyed by the configured passphrase. It is host-agnostic: it depends on the +/// `HostSignals` capability, so any host that supplies one can use it. A host +/// that supplies no `HostSignals` cannot build it, and the request stops. +#[derive(Debug, Clone)] +pub struct HostSignalProvider { + passphrase: Redacted, + host_signals: Arc, +} + +impl HostSignalProvider { + /// Creates the provider with the passphrase and its injected host signals. + #[must_use] + pub fn new(passphrase: Redacted, host_signals: Arc) -> Self { + Self { + passphrase, + host_signals, + } + } +} + +impl EdgeCookieProvider for HostSignalProvider { + fn id(&self) -> &'static str { + "host-signals" + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let ja4 = self.host_signals.ja4().unwrap_or_default(); + let h2 = self.host_signals.h2().unwrap_or_default(); + let id = generation::generate_hmac_ec_id( + self.passphrase.expose(), + &[ja4, h2, request_info.client_ip()], + )?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } + + fn required_permissions(&self) -> PermissionSet { + // Writes the Edge Cookie to the device, so it requires store-on-device + // (TCF Purpose 1), the same gate as the HMAC provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + +/// The fixed, known word shared by [`ClientFixedProvider`] and its page script. +/// +/// Kept cookie-safe (no characters [`set_provider_ec_cookie`] would reject) so +/// it can be used as the Edge Cookie value verbatim. The page script posts this +/// exact string; the provider mints only when the posted value matches. The +/// client copy lives in +/// `crates/trusted-server-js/lib/src/integrations/ec_client_fixed`. +/// +/// [`set_provider_ec_cookie`]: super::cookies::set_provider_ec_cookie +const EXPECTED_VALUE: &str = "an-ec"; + +/// A demonstration client-side provider, with no vendor coupling. +/// +/// Client and server share one fixed, known word (`EXPECTED_VALUE`). When no +/// Edge Cookie is present the page script (delivered through the tsjs bundle) +/// posts that word to `POST /_ts/api/v1/ec/resolve`, and this provider mints the +/// Edge Cookie only when the posted value matches. It defers from +/// [`generate`](EdgeCookieProvider::generate) so the page renders with no Edge +/// Cookie until the client reports back, then verifies and mints in +/// [`resolve_from_client`](EdgeCookieProvider::resolve_from_client). +/// +/// The value is verifiable precisely because it is a known constant, which is +/// the point of the demo: it exercises verify-before-mint. It is useless in +/// production, because a fixed value is not an identity and every client posts +/// the same word, so it is for demonstration and testing only. A real +/// client-side provider verifies a real payload (for example an OWID signature) +/// instead of a shared constant. +#[derive(Debug, Clone)] +pub struct ClientFixedProvider; + +impl EdgeCookieProvider for ClientFixedProvider { + fn id(&self) -> &'static str { + "client-fixed" + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + // No identifier is derived at the edge: the value comes from the page + // script, which posts it to the resolve endpoint. + Ok(GeneratedEdgeCookie::default()) + } + + fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + ) -> Result> { + // Verify the posted value against the known shared word, then mint it as + // the Edge Cookie. A value that does not match yields no Edge Cookie. + // This stands in for a real provider's verification (for example + // checking a signature) before it trusts a client-supplied value. + let matches = core::str::from_utf8(input.payload) + .map(str::trim) + .is_ok_and(|value| value == EXPECTED_VALUE); + + Ok(GeneratedEdgeCookie { + id: matches.then(|| EXPECTED_VALUE.to_owned()), + response_headers: Vec::new(), + }) + } + + fn required_permissions(&self) -> PermissionSet { + // The provider writes the resolved value to the device as the Edge + // Cookie, so it requires store-on-device (TCF Purpose 1), the same gate + // as the HMAC provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + +/// Builds the Edge Cookie provider named by the `[ec] provider` selector, +/// injecting the services it needs. +/// +/// This is the composition root for the built-in providers: the adapter supplies +/// the [`HostSignals`] when the host can produce them, and this constructs the +/// selected provider. The per-request [`RequestInfo`] is passed borrowed to +/// [`generate`](EdgeCookieProvider::generate) at call time rather than stored, so +/// no request snapshot is cloned here. Returns `Ok(None)` when no provider is +/// selected, so the caller stays stateless. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider requires +/// a service the host did not supply (for example the host-signal provider on a +/// host that exposes no [`HostSignals`]), so a misconfigured deployment fails +/// loudly rather than minting a degraded identifier. +pub fn build_provider( + ec: &Ec, + host_signals: Option>, +) -> Result>, Report> { + let Some(key) = ec.provider.as_deref() else { + return Ok(None); + }; + let provider: Option> = match key { + "hmac" => ec + .providers + .hmac + .as_ref() + .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), + // The client-fixed demo provider takes no configuration or services, so + // it is built whenever it is selected. For demonstration and testing + // only (see [`ClientFixedProvider`]). + "client-fixed" => Some(Box::new(ClientFixedProvider) as _), + "host-signals" => { + let signals = host_signals.ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "The host-signals Edge Cookie provider requires a host that supplies \ + TLS/HTTP-2 fingerprints, which this host does not" + .to_owned(), + }) + })?; + ec.providers.host_signals.as_ref().map(|config| { + Box::new(HostSignalProvider::new(config.passphrase.clone(), signals)) as _ + }) + } + _ => None, + }; + Ok(provider) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::evidence::OwnedRequestInfo; + use crate::permissions::PermissionMaps; + use crate::redacted::Redacted; + + fn test_passphrase() -> Redacted { + Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) + } + + fn test_request_info() -> OwnedRequestInfo { + OwnedRequestInfo::new("203.0.113.1".to_owned(), http::HeaderMap::new()) + } + + /// Test host signals with fixed JA4/H2 values. + #[derive(Debug)] + struct TestHostSignals { + ja4: Option, + h2: Option, + } + + impl HostSignals for TestHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } + } + + /// A provider whose identifiers wrap a payload after a `:` separator, so two + /// different wrappers of the same payload denote the same identity. Stands in + /// for an envelope-based vendor identifier. + #[derive(Debug)] + struct WrappedPayloadProvider; + + impl EdgeCookieProvider for WrappedPayloadProvider { + fn id(&self) -> &'static str { + "wrapped" + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn keys_equal(&self, left: &str, right: &str) -> bool { + fn payload(value: &str) -> &str { + value.split_once(':').map_or(value, |(_, payload)| payload) + } + payload(left) == payload(right) + } + } + + #[test] + fn hmac_keys_equal_uses_natural_equality() { + let provider = HmacProvider::new(test_passphrase()); + assert!( + provider.keys_equal("abcd.efghij", "abcd.efghij"), + "identical HMAC keys should be equal" + ); + assert!( + !provider.keys_equal("abcd.efghij", "abcd.klmnop"), + "different HMAC keys should not be equal" + ); + } + + #[test] + fn keys_equal_can_compare_by_payload_ignoring_the_wrapper() { + let provider = WrappedPayloadProvider; + assert!( + provider.keys_equal("wrapper-1:same-payload", "wrapper-2:same-payload"), + "different wrappers of the same payload should be equal" + ); + assert!( + !provider.keys_equal("wrapper-1:payload-a", "wrapper-1:payload-b"), + "different payloads should not be equal" + ); + } + + #[test] + fn hmac_provider_requires_store_on_device() { + let provider = HmacProvider::new(test_passphrase()); + let required = provider.required_permissions(); + assert!( + required.contains(Permission::StoreOnDevice), + "the HMAC provider writes a cookie, so it requires store-on-device" + ); + assert!( + !required.contains(Permission::SelectPersonalisedAds), + "the HMAC provider requires no advertising permissions" + ); + } + + #[test] + fn host_signal_provider_mints_from_fingerprints_and_requires_store_on_device() { + let signals = Arc::new(TestHostSignals { + ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_owned()), + h2: Some("1:65536;4:6291456".to_owned()), + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_some(), + "the host-signal provider mints an identifier from the fingerprints" + ); + assert!( + provider + .required_permissions() + .contains(Permission::StoreOnDevice), + "the host-signal provider writes a cookie, so it requires store-on-device" + ); + } + + #[test] + fn a_neutral_provider_requires_no_permissions_by_default() { + // The wrapped-payload stub does not override required_permissions, so it + // inherits the trait default of none and requires no permission. + assert!( + WrappedPayloadProvider.required_permissions().is_empty(), + "a vendor-neutral provider requires nothing by default" + ); + } + + #[test] + fn the_edge_cookie_gate_blocks_until_the_permission_is_set() { + let required = HmacProvider::new(test_passphrase()).required_permissions(); + // Empty maps with no default: every permission is the requires-signal + // floor. + let maps = PermissionMaps::empty(); + + // No signal: the provider's required permission is not set, so Trusted + // Server would not commit the Edge Cookie. + assert!( + !maps.resolve(None, None, |_| false).all_set(required), + "the floor should not run the Edge Cookie provider without the permission set" + ); + + // A grant signal for store-on-device: the provider's permission is now set. + assert!( + maps.resolve(None, None, |p| p == Permission::StoreOnDevice) + .all_set(required), + "the Edge Cookie provider runs once store-on-device is set" + ); + } + + #[test] + fn client_fixed_defers_in_generate() { + let request_info = test_request_info(); + let generated = ClientFixedProvider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_none(), + "client-fixed should defer in generate, deriving no edge identifier" + ); + } + + #[test] + fn client_fixed_mints_when_posted_word_matches() { + let input = ClientResolveInput { + payload: EXPECTED_VALUE.as_bytes(), + permissions: None, + consent: None, + }; + let generated = ClientFixedProvider + .resolve_from_client(&input) + .expect("should resolve"); + assert_eq!( + generated.id.as_deref(), + Some(EXPECTED_VALUE), + "the known shared word should verify and mint the Edge Cookie" + ); + } + + #[test] + fn client_fixed_rejects_unknown_word() { + let input = ClientResolveInput { + payload: b"not-the-word", + permissions: None, + consent: None, + }; + let generated = ClientFixedProvider + .resolve_from_client(&input) + .expect("should resolve"); + assert!( + generated.id.is_none(), + "a value that does not match the known word should mint no Edge Cookie" + ); + } + + #[test] + fn client_fixed_requires_store_on_device() { + assert!( + ClientFixedProvider + .required_permissions() + .contains(Permission::StoreOnDevice), + "client-fixed writes a cookie, so it requires store-on-device" + ); + } + + #[test] + fn server_side_provider_inherits_no_op_resolve_from_client() { + // HmacProvider does not override resolve_from_client, so it inherits the + // no-op default: a server-side provider does not participate in the + // client cycle. + let provider = HmacProvider::new(test_passphrase()); + let input = ClientResolveInput { + payload: b"anything", + permissions: None, + consent: None, + }; + let generated = provider + .resolve_from_client(&input) + .expect("should resolve"); + assert!( + generated.id.is_none(), + "a server-side provider inherits the no-op resolve_from_client default" + ); + } +} diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs new file mode 100644 index 000000000..5b3999011 --- /dev/null +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -0,0 +1,255 @@ +//! Client-cycle Edge Cookie resolution endpoint (`POST /_ts/api/v1/ec/resolve`). +//! +//! A client-side Edge Cookie provider defers on the organic page request +//! (deriving no identifier at the edge) and lets the page do the work in the +//! browser. When the page has its result it posts the value here, and this +//! endpoint hands it to the configured provider's +//! [`resolve_from_client`](super::provider::EdgeCookieProvider::resolve_from_client) +//! to mint the Edge Cookie. +//! +//! The endpoint is provider-agnostic: it bounds the body, gates on the +//! permission model (the same gate as organic generation), calls the provider, +//! and sets the cookie on its own response so the value is live for every +//! subsequent first-party request. Whether the posted value is trustworthy is +//! the provider's responsibility. The payload arrives from the browser, so a +//! real provider verifies it (for example an OWID signature) before minting. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::{header, Request, Response, StatusCode}; + +use crate::error::TrustedServerError; +use crate::settings::Settings; + +use super::cookies::set_provider_ec_cookie; +use super::provider::{build_provider, ClientResolveInput}; +use super::EcContext; + +/// Maximum size of a resolve request body. +/// +/// Client-cycle payloads (a random value, or a signed envelope such as a +/// vendor JSON payload) are small; this bound guards against an oversized body +/// before it is read into memory. +const MAX_BODY_SIZE: usize = 64 * 1024; + +/// Handles `POST /_ts/api/v1/ec/resolve`. +/// +/// Gates on the configured provider's required permissions, then asks the +/// provider to mint an Edge Cookie from the posted payload. On success the EC +/// cookie is set on this response (so the browser carries it on subsequent +/// first-party requests) and the status is `200`. When the gate is closed, no +/// provider is configured, or the provider produces no identifier, the response +/// is `204` with no cookie. An oversized body is rejected with `413`. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when the provider fails to process the +/// payload. A payload that is merely unverified or absent yields a `204` +/// rather than an error. +pub fn handle_ec_resolve( + settings: &Settings, + req: Request, + ec_context: &EcContext, +) -> Result, Report> { + // Gate: the configured provider's required permissions must be set for + // this request, the same gate as the organic generation path. A + // client-driven resolve does not bypass the permission model. + if !ec_context.ec_allowed() { + log::info!("EC resolve skipped: required permissions not set"); + return Ok(status_only(StatusCode::NO_CONTENT)); + } + + // Rebuild the provider with the same host signals captured on the context, so + // a provider that needs a service the host cannot supply fails here. The + // client value is verified from the posted body below, not from request info. + let Some(provider) = build_provider(&settings.ec, ec_context.host_signals())? else { + log::info!("EC resolve skipped: no Edge Cookie provider configured"); + return Ok(status_only(StatusCode::NO_CONTENT)); + }; + + // Bound the body before reading it into memory. + if content_length_exceeds_limit(&req, MAX_BODY_SIZE) { + return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); + } + let payload = req.into_body().into_bytes().unwrap_or_default(); + if payload.len() > MAX_BODY_SIZE { + return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); + } + + let input = ClientResolveInput { + payload: payload.as_ref(), + permissions: Some(ec_context.permissions()), + consent: Some(ec_context.consent()), + }; + + let generated = provider.resolve_from_client(&input)?; + log::debug!( + "EC resolve handled (provider={}): id {}", + provider.id(), + if generated.id.is_some() { + "minted" + } else { + "not minted" + }, + ); + + let mut response = status_only(if generated.id.is_some() { + StatusCode::OK + } else { + StatusCode::NO_CONTENT + }); + + // Apply any response headers the provider asked for (for example to request + // more client evidence on a later request). Empty for the demo provider. + for (name, value) in generated.response_headers { + response.headers_mut().insert(name, value); + } + + if let Some(ec_id) = generated.id { + set_provider_ec_cookie(settings, &mut response, &ec_id); + } + + Ok(response) +} + +/// Builds a bodiless response with the given status. +fn status_only(status: StatusCode) -> Response { + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = status; + response +} + +/// Returns `true` when the request advertises a `Content-Length` over `limit`. +fn content_length_exceeds_limit(req: &Request, limit: usize) -> bool { + req.headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|len| len > limit) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::types::ConsentContext; + use crate::test_support::tests::create_test_settings; + use http::Method; + + fn settings_with_client_fixed() -> Settings { + let mut settings = create_test_settings(); + settings.ec.provider = Some("client-fixed".to_owned()); + settings + } + + // The fixed word shared by the client-fixed provider and its page script. + const FIXED_WORD: &str = "an-ec"; + + fn post(body: &str) -> Request { + Request::builder() + .method(Method::POST) + .uri("https://edge.example.com/_ts/api/v1/ec/resolve") + .body(EdgeBody::from(body.to_owned())) + .expect("should build resolve request") + } + + fn gated(ec_allowed: bool) -> EcContext { + EcContext::new_for_test_gated(None, ConsentContext::default(), ec_allowed) + } + + #[test] + fn resolve_sets_cookie_when_word_matches_and_allowed() { + let settings = settings_with_client_fixed(); + let response = handle_ec_resolve(&settings, post(FIXED_WORD), &gated(true)) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::OK, + "a verified value should return 200" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be utf-8"); + assert!( + set_cookie.contains(FIXED_WORD), + "should set the verified known word as the EC cookie, got {set_cookie}" + ); + assert!( + set_cookie.contains("HttpOnly"), + "the EC cookie should be HttpOnly" + ); + assert!( + set_cookie.contains("Secure"), + "the EC cookie should be Secure" + ); + } + + #[test] + fn resolve_returns_204_when_not_allowed() { + let settings = settings_with_client_fixed(); + let response = handle_ec_resolve(&settings, post(FIXED_WORD), &gated(false)) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "a closed permission gate should return 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a closed gate should set no cookie" + ); + } + + #[test] + fn resolve_returns_204_when_no_provider_configured() { + let mut settings = create_test_settings(); + settings.ec.provider = None; + let response = + handle_ec_resolve(&settings, post("123"), &gated(true)).expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "no configured provider should return 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "no configured provider should set no cookie" + ); + } + + #[test] + fn resolve_rejects_oversized_body() { + let settings = settings_with_client_fixed(); + let big = "x".repeat(MAX_BODY_SIZE + 1); + let response = + handle_ec_resolve(&settings, post(&big), &gated(true)).expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "an oversized body should be rejected with 413" + ); + } + + #[test] + fn resolve_sets_no_cookie_for_unmatched_word() { + let settings = settings_with_client_fixed(); + let response = handle_ec_resolve(&settings, post("not-the-word"), &gated(true)) + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "a value that fails verification should yield no cookie and a 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an unmatched value should set no cookie" + ); + } +} diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 05eaf310f..b3bc089bb 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -10,37 +10,54 @@ use http::Request; use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; -use crate::ec::generation::{generate_ec_id as generate_canonical_ec_id, normalize_ip}; +use crate::ec::generation::normalize_ip; +use crate::ec::provider::{build_provider, IdentityInput}; use crate::error::TrustedServerError; +use crate::evidence::BorrowedRequestInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; -/// Generates a fresh EC ID based on client IP address. +/// Generates a fresh EC ID using the configured Edge Cookie provider. /// -/// Delegates to the canonical generator in [`crate::ec::generation`] so a -/// single normalization + HMAC path produces EC IDs. The canonical -/// `normalize_ip` format is a stable contract — EC hashes stored in KV -/// depend on it, and a divergent normalization would mint non-correlating -/// identities for the same client. +/// Routes through the pluggable provider model: the active `[ec] provider` +/// selection decides the outcome. Returns `Ok(None)` when no provider is +/// configured, so Trusted Server runs statelessly and mints no Edge Cookie. +/// `request_headers` lets a provider that derives identity from request +/// evidence read it; the built-in HMAC provider ignores it and uses only the +/// normalized client IP. /// /// # Errors /// -/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +/// - [`TrustedServerError::EdgeCookie`] if provider generation fails pub fn generate_ec_id( settings: &Settings, services: &RuntimeServices, -) -> Result> { - // Fallback to "unknown" when client IP is unavailable (e.g., local testing). - // All such requests share the same HMAC base; the random suffix provides uniqueness. + request_headers: Option<&http::HeaderMap>, +) -> Result, Report> { + // Fall back to "unknown" when the client IP is unavailable (for example in + // local testing). All such requests share the same HMAC base; the random + // suffix provides uniqueness. let client_ip = services - .client_info + .client_info() .client_ip .map(normalize_ip) .unwrap_or_else(|| "unknown".to_string()); log::trace!("Generating fresh EC ID from normalized client context"); - generate_canonical_ec_id(settings, &client_ip) + let Some(provider) = build_provider(&settings.ec, services.host_signals())? else { + log::info!("No Edge Cookie provider configured; running statelessly"); + return Ok(None); + }; + + // The provider reads request data (the client IP, and on a fingerprinting + // host the TLS/HTTP-2 signals) borrowed at call time, so nothing is cloned. + let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); + // The publisher path applies the permission gate at the call site, and the + // built-in provider reads neither the resolved permissions nor consent, so + // they are not threaded here. + let generated = provider.generate(&request_info, &IdentityInput::default())?; + Ok(generated.id) } /// Gets an existing EC ID from the request. @@ -92,7 +109,10 @@ pub fn get_ec_id(req: &Request) -> Result, Report, -) -> Result> { +) -> Result, Report> { if let Some(id) = get_ec_id(req)? { - return Ok(id); + return Ok(Some(id)); } - // If no existing EC ID found, generate a fresh one - let ec_id = generate_ec_id(settings, services)?; - log::trace!("No existing EC ID found; generated a fresh EC ID"); + // If no existing EC ID found, generate a fresh one through the provider. + let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + if ec_id.is_some() { + log::trace!("No existing EC ID found; generated a fresh EC ID"); + } Ok(ec_id) } @@ -122,7 +144,7 @@ pub fn get_or_generate_ec_id( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { +) -> Result, Report> { get_or_generate_ec_id_from_http_request(settings, services, req) } @@ -133,6 +155,7 @@ mod tests { use http::{header, HeaderName}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use crate::ec::generation::generate_ec_id as generate_canonical_ec_id; use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; use crate::test_support::tests::create_test_settings; @@ -147,9 +170,17 @@ mod tests { 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234, )); - let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID via edge_cookie"); - let id_canonical = generate_canonical_ec_id(&settings, &normalize_ip(ip)) + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID via edge_cookie") + .expect("should configure the hmac provider in test settings"); + let passphrase = settings + .ec + .providers + .hmac + .as_ref() + .map(|hmac| hmac.passphrase.expose().as_str()) + .unwrap_or(""); + let id_canonical = generate_canonical_ec_id(passphrase, &normalize_ip(ip)) .expect("should generate EC ID via canonical generator"); assert_eq!( @@ -198,7 +229,9 @@ mod tests { fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, &noop_services()).expect("should generate EC ID"); + let ec_id = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID") + .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); assert!( is_ec_id_format(&ec_id), @@ -206,15 +239,31 @@ mod tests { ); } + #[test] + fn generate_ec_id_returns_none_when_no_provider_is_configured() { + let mut settings = create_test_settings(); + // No provider selected: Trusted Server runs statelessly. + settings.ec.provider = None; + + let id = generate_ec_id(&settings, &noop_services(), None) + .expect("generation should not error when no provider is configured"); + assert!( + id.is_none(), + "no Edge Cookie provider should mean no Edge Cookie is minted" + ); + } + #[test] fn test_generate_ec_id_uses_client_ip() { let settings = create_test_settings(); let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); - let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID with client IP"); - let id_without_ip = generate_ec_id(&settings, &noop_services()) - .expect("should generate EC ID without client IP"); + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID with client IP") + .expect("should configure the hmac provider in test settings"); + let id_without_ip = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID without client IP") + .expect("should configure the hmac provider in test settings"); let hmac_with_ip = id_with_ip.split_once('.').expect("should contain dot").0; let hmac_without_ip = id_without_ip.split_once('.').expect("should contain dot").0; @@ -270,7 +319,8 @@ mod tests { assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse header EC ID"); + .expect("should reuse header EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_ec_id"); } @@ -286,7 +336,8 @@ mod tests { assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID"); + .expect("should reuse cookie EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_cookie_id"); } @@ -318,7 +369,8 @@ mod tests { .expect("should build test request"); let ec_id = get_or_generate_ec_id_from_http_request(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID from http request"); + .expect("should reuse cookie EC ID from http request") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_http_cookie_id"); } @@ -336,7 +388,8 @@ mod tests { let req = create_test_request(&[]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should get or generate EC ID"); + .expect("should get or generate EC ID") + .expect("should configure the hmac provider in test settings"); assert!(!ec_id.is_empty()); } @@ -361,7 +414,8 @@ mod tests { let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should generate fresh ID on invalid header"); + .expect("should generate fresh ID on invalid header") + .expect("should configure the hmac provider in test settings"); assert_ne!( ec_id, "evil;injected", "should not use tampered header value" diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs new file mode 100644 index 000000000..07ede9d93 --- /dev/null +++ b/crates/trusted-server-core/src/evidence.rs @@ -0,0 +1,141 @@ +//! Service interfaces injected into providers. +//! +//! Trusted Server wires providers by dependency injection. A provider's +//! constructor takes the services it needs as `Arc`, and the adapter +//! (the composition root) supplies instances per request. A provider that needs +//! a service the host does not supply cannot be built, so the request stops +//! rather than silently degrading. +//! +//! These traits are the service interfaces. Concrete implementations live with +//! the host or vendor that supplies them (for example `FastlyHostSignals` in +//! `trusted-server-device-fastly`). An injected service outlives the borrow of +//! the live request, so an implementation owns its data ([`OwnedRequestInfo`] is +//! the built-in owned snapshot). + +use http::HeaderMap; + +/// Host-computed client fingerprints that are not carried in request headers. +/// +/// A host that can compute them supplies an implementation (Fastly exposes the +/// TLS JA4 and HTTP/2 fingerprints). A provider that needs them takes +/// `Arc` in its constructor; on a host that supplies none, the +/// provider cannot be built and the request stops. +pub trait HostSignals: Send + Sync + core::fmt::Debug { + /// The full JA4 TLS fingerprint, or `None` when unavailable. + fn ja4(&self) -> Option<&str>; + + /// The raw HTTP/2 SETTINGS fingerprint, or `None` when unavailable. + fn h2(&self) -> Option<&str>; +} + +/// Read-only access to the current request's basic information. +/// +/// The request data any host can supply: the normalized client IP, the +/// User-Agent, and request headers. A provider receives it by reference at call +/// time (`generate`/`detect`), reads what it needs, and does not retain it. +pub trait RequestInfo: Send + Sync + core::fmt::Debug { + /// The normalized client IP, or `""` when the host cannot determine it. + fn client_ip(&self) -> &str; + + /// The `User-Agent` header value, or `""` when absent. + fn user_agent(&self) -> &str; + + /// An arbitrary request header by name (case-insensitive), or `None`. + fn header(&self, name: &str) -> Option<&str>; + + /// The names of all request headers present, for a provider that enumerates + /// evidence (for example to forward client hints). The default is empty. + fn header_names(&self) -> Vec<&str> { + Vec::new() + } +} + +/// An owned [`RequestInfo`] built from a request snapshot. +/// +/// Owns the client IP and a header snapshot, for a context that cannot borrow +/// the live request for the duration of the call. The request path uses +/// [`BorrowedRequestInfo`]; this owned variant serves tests and any future +/// host whose request data cannot be borrowed. +#[derive(Debug)] +pub struct OwnedRequestInfo { + client_ip: String, + headers: HeaderMap, +} + +impl OwnedRequestInfo { + /// Builds owned request info from the client IP and a header snapshot. + #[must_use] + pub fn new(client_ip: String, headers: HeaderMap) -> Self { + Self { client_ip, headers } + } +} + +impl RequestInfo for OwnedRequestInfo { + fn client_ip(&self) -> &str { + &self.client_ip + } + + fn user_agent(&self) -> &str { + self.headers + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|value| value.to_str().ok()) + } + + fn header_names(&self) -> Vec<&str> { + self.headers.keys().map(http::HeaderName::as_str).collect() + } +} + +/// A borrowed [`RequestInfo`] over the live request, with no allocation. +/// +/// The composition root builds one per request from the normalized client IP and +/// an optional borrow of the request headers, then passes it to a provider by +/// shared reference at call time (`generate`/`detect`). It borrows rather than +/// owns, so it must not outlive the request. A provider reads it during the call +/// and does not retain it, so no per-request `HeaderMap` clone is needed. +#[derive(Debug)] +pub struct BorrowedRequestInfo<'a> { + client_ip: &'a str, + headers: Option<&'a HeaderMap>, +} + +impl<'a> BorrowedRequestInfo<'a> { + /// Borrows request info from the client IP and optional request headers. + /// + /// Pass `None` for headers on a path that only needs the client IP (for + /// example the Edge Cookie generate path), so no header access is retained. + #[must_use] + pub fn new(client_ip: &'a str, headers: Option<&'a HeaderMap>) -> Self { + Self { client_ip, headers } + } +} + +impl RequestInfo for BorrowedRequestInfo<'_> { + fn client_ip(&self) -> &str { + self.client_ip + } + + fn user_agent(&self) -> &str { + self.headers + .and_then(|headers| headers.get(http::header::USER_AGENT)) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers + .and_then(|headers| headers.get(name)) + .and_then(|value| value.to_str().ok()) + } + + fn header_names(&self) -> Vec<&str> { + self.headers + .map(|headers| headers.keys().map(http::HeaderName::as_str).collect()) + .unwrap_or_default() + } +} diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 63f7907f5..22a37ad2c 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -59,39 +59,6 @@ fn insert_geo_header(headers: &mut http::HeaderMap, name: http::header::HeaderNa } } -use std::collections::HashSet; -use std::sync::LazyLock; - -/// EU-27 + EEA-3 (Iceland, Liechtenstein, Norway) + UK (UK GDPR). -/// -/// Two-letter ISO 3166-1 alpha-2 country codes for jurisdictions where GDPR -/// or equivalent legislation applies. Used to infer GDPR applicability from -/// IP-derived geolocation when a more authoritative signal (e.g. TCF consent -/// string) is not yet available. -static GDPR_COUNTRIES: LazyLock> = LazyLock::new(|| { - [ - // EU-27 - "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", - "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", - // EEA (non-EU) - "IS", "LI", "NO", // UK GDPR - "GB", - ] - .into_iter() - .collect() -}); - -/// Returns `true` if the given two-letter country code falls under GDPR -/// jurisdiction (EU-27, EEA, or UK). -/// -/// The comparison is case-insensitive. Returns `false` for empty or -/// unrecognised codes. -#[must_use] -pub fn is_gdpr_country(country_code: &str) -> bool { - let upper = country_code.to_ascii_uppercase(); - GDPR_COUNTRIES.contains(upper.as_str()) -} - #[cfg(test)] mod tests { use super::*; @@ -217,39 +184,6 @@ mod tests { ); } - #[test] - fn is_gdpr_country_detects_eu_members() { - assert!(is_gdpr_country("DE"), "Germany is EU"); - assert!(is_gdpr_country("FR"), "France is EU"); - assert!(is_gdpr_country("IT"), "Italy is EU"); - } - - #[test] - fn is_gdpr_country_detects_eea_and_uk() { - assert!(is_gdpr_country("NO"), "Norway is EEA"); - assert!(is_gdpr_country("IS"), "Iceland is EEA"); - assert!(is_gdpr_country("GB"), "UK has UK GDPR"); - } - - #[test] - fn is_gdpr_country_rejects_non_gdpr() { - assert!(!is_gdpr_country("US"), "US is not GDPR"); - assert!(!is_gdpr_country("CN"), "China is not GDPR"); - assert!(!is_gdpr_country("BR"), "Brazil is not GDPR"); - } - - #[test] - fn is_gdpr_country_is_case_insensitive() { - assert!(is_gdpr_country("de"), "lowercase should match"); - assert!(is_gdpr_country("De"), "mixed case should match"); - } - - #[test] - fn is_gdpr_country_handles_empty_and_unknown() { - assert!(!is_gdpr_country(""), "empty string is not GDPR"); - assert!(!is_gdpr_country("XX"), "unknown code is not GDPR"); - } - #[test] fn set_response_headers_omits_region_when_none() { let geo = GeoInfo { diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 84336e3e4..cfd0fb687 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1530,12 +1530,18 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] enabled = true container_id = "GTM-PARSED" upstream_url = "https://custom.gtm.example" + +[geo] +default_country = "FR" "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings @@ -1563,10 +1569,16 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] container_id = "GTM-DEFAULT" + +[geo] +default_country = "FR" "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d2bceb43a..595c5a2d6 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1073,8 +1073,9 @@ impl PrebidAuctionProvider { .map(|ac| ConsentedProvidersSettings { consented_providers: Some(ac.clone()), }), - // EIDs resolved from the KV identity graph and consent-gated - // in `handle_auction` via `gate_eids_by_consent`. + // EIDs resolved from the KV identity graph and gated on the + // resolved permission state in `handle_auction` via + // `gate_eids_by_permissions`. eids: request.user.eids.clone(), } .to_ext(), @@ -1915,7 +1916,13 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + +[geo] +default_country = "FR" "#; /// Parse a TOML string containing only the `[integrations.prebid]` section diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 103657e3e..62b71dab2 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -577,6 +577,18 @@ pub trait IntegrationHeadInjector: Send + Sync { fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; } +/// Trait for integration-provided outbound response header mutations. +/// +/// Lets an integration add headers (for example `Accept-CH` or detection result +/// headers) to outbound responses. Applied on the platform-neutral +/// [`http::Response`] before it is handed to the host adapter. +pub trait IntegrationResponseMutator: Send + Sync { + /// Identifier for logging/diagnostics. + fn integration_id(&self) -> &'static str; + /// Add headers to an outbound response. + fn add_response_headers(&self, headers: &mut http::HeaderMap); +} + /// Registration payload returned by integration builders. pub struct IntegrationRegistration { pub integration_id: &'static str, @@ -587,6 +599,7 @@ pub struct IntegrationRegistration { pub html_post_processors: Vec>, pub head_injectors: Vec>, pub request_filters: Vec>, + pub response_mutators: Vec>, } impl IntegrationRegistration { @@ -612,6 +625,7 @@ impl IntegrationRegistrationBuilder { html_post_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), + response_mutators: Vec::new(), }, } } @@ -658,6 +672,12 @@ impl IntegrationRegistrationBuilder { self } + #[must_use] + pub fn with_response_mutator(mut self, mutator: Arc) -> Self { + self.registration.response_mutators.push(mutator); + self + } + /// Mark this integration's JS module for deferred loading via /// `