From 4b0d3cb59e7a7a5c4380e2a1c672d1d0d99b47a6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 19 Oct 2026 13:36:58 +0530 Subject: [PATCH 1/2] Use trusted geography for all Didomi SDK requests --- .../src/integrations/didomi.rs | 342 ++++++++++++++++-- docs/guide/integrations/didomi.md | 62 +++- .../plans/2026-10-19-didomi-trusted-geo.md | 74 ++++ .../2026-10-19-didomi-trusted-geo-design.md | 59 +++ 4 files changed, 487 insertions(+), 50 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-19-didomi-trusted-geo.md create mode 100644 docs/superpowers/specs/2026-10-19-didomi-trusted-geo-design.md diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index e1e7c87e88..8a24ead128 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -289,12 +289,10 @@ impl DidomiIntegration { } } - if matches!(backend, DidomiBackend::Sdk) { - if let Some(geo) = authoritative_geo { - Self::set_geo_headers(geo, proxy_headers); - } else { - Self::copy_geo_headers(original_headers, proxy_headers); - } + if matches!(backend, DidomiBackend::Sdk) + && let Some(geo) = authoritative_geo + { + Self::set_geo_headers(geo, proxy_headers); } } @@ -311,23 +309,6 @@ impl DidomiIntegration { } } - fn copy_geo_headers( - original_headers: &HeaderMap, - proxy_headers: &mut HeaderMap, - ) { - let geo_headers = [ - ("X-Geo-Country", "FastlyGeo-CountryCode"), - ("X-Geo-Region", "FastlyGeo-Region"), - ("CloudFront-Viewer-Country", "FastlyGeo-CountryCode"), - ]; - - for (target, source) in geo_headers { - if let Some(value) = original_headers.get(source) { - proxy_headers.insert(target, value.clone()); - } - } - } - fn add_cors_headers(response: &mut http::Response) { response.headers_mut().insert( header::ACCESS_CONTROL_ALLOW_ORIGIN, @@ -434,19 +415,27 @@ impl IntegrationProxy for DidomiIntegration { let prefix = self.resolved_prefix(); let consent_path = path.strip_prefix(&prefix).unwrap_or(&path); let backend = self.backend_for_path(consent_path); + let trusted_geo = if matches!(backend, DidomiBackend::Sdk) { + match services.geo().lookup(services.client_info().client_ip) { + Ok(Some(geo)) => normalize_didomi_geo(&geo) + .map(Some) + .map_err(DidomiGeoError::reason), + Ok(None) => Err("geo_unavailable"), + Err(_) => Err("lookup_failed"), + } + } else { + Ok(None) + }; let canonical_loader = if self.config.geo_query_parameters && matches!(backend, DidomiBackend::Sdk) && is_notice_loader(&parts.method, consent_path) { - let geo = match services.geo().lookup(services.client_info().client_ip) { - Ok(Some(geo)) => match normalize_didomi_geo(&geo) { - Ok(geo) => geo, - Err(error) => return Ok(Self::geo_failure_response(error.reason())), - }, + let geo = match &trusted_geo { + Ok(Some(geo)) => geo, Ok(None) => return Ok(Self::geo_failure_response("geo_unavailable")), - Err(_) => return Ok(Self::geo_failure_response("lookup_failed")), + Err(reason) => return Ok(Self::geo_failure_response(reason)), }; - let canonical = canonical_loader_url(&path, parts.uri.query(), &geo); + let canonical = canonical_loader_url(&path, parts.uri.query(), geo); let incoming_path_and_query = parts .uri .path_and_query() @@ -454,7 +443,7 @@ impl IntegrationProxy for DidomiIntegration { if incoming_path_and_query != canonical.browser_target { return Self::redirect_response(&canonical.browser_target); } - Some((geo, canonical)) + Some(canonical) } else { None }; @@ -462,10 +451,9 @@ impl IntegrationProxy for DidomiIntegration { DidomiBackend::Sdk => self.config.sdk_origin.as_str(), DidomiBackend::Api => self.config.api_origin.as_str(), }; - let query = canonical_loader.as_ref().map_or_else( - || parts.uri.query(), - |(_, canonical)| Some(&*canonical.query), - ); + let query = canonical_loader + .as_ref() + .map_or_else(|| parts.uri.query(), |canonical| Some(&*canonical.query)); let target_url = self .build_target_url(base_origin, consent_path, query) @@ -492,14 +480,15 @@ impl IntegrationProxy for DidomiIntegration { services.client_info().client_ip, &parts.headers, proxy_req.headers_mut(), - canonical_loader.as_ref().map(|(geo, _)| geo), + trusted_geo.as_ref().ok().and_then(Option::as_ref), ); let platform_request = PlatformHttpRequest::new(proxy_req, backend_name); - let platform_request = if matches!(backend, DidomiBackend::Api) { - platform_request.with_cache_bypass() - } else { + // Only validated canonical loader URLs identify the trusted geo in cache keys. + let platform_request = if canonical_loader.is_some() { platform_request + } else { + platform_request.with_cache_bypass() }; let mut response = services .http_client() @@ -509,7 +498,8 @@ impl IntegrationProxy for DidomiIntegration { if matches!(backend, DidomiBackend::Sdk) { Self::add_cors_headers(&mut response.response); - } else { + } + if canonical_loader.is_none() { crate::response_privacy::enforce_terminal_private_cache_privacy(&mut response.response); } @@ -978,6 +968,278 @@ mod tests { ); } + const NONCANONICAL_SDK_CASES: [(bool, Method, &str); 5] = [ + (false, Method::GET, "/key/loader.js"), + (false, Method::GET, "/sdk/v1/core.js"), + (true, Method::GET, "/sdk/v1/core.js"), + (false, Method::POST, "/key/loader.js"), + (true, Method::POST, "/key/loader.js"), + ]; + + #[test] + fn noncanonical_sdk_uses_trusted_geo_instead_of_caller_headers() { + for (enabled, method, path) in NONCANONICAL_SDK_CASES { + // Arrange conflicting caller geography and a normalized platform pair. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"sdk".to_vec()); + let services = services_with_geo( + Arc::clone(&stub), + GeoResult::Value(Some(geo_info(" us ", Some(" us-ca ")))), + ); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(DidomiIntegrationConfig { + geo_query_parameters: enabled, + ..config(true) + })); + let request = http::Request::builder() + .method(method) + .uri(format!( + "https://publisher.example/integrations/didomi/consent{path}?v=1" + )) + .header("FastlyGeo-CountryCode", "GB") + .header("FastlyGeo-Region", "LND") + .header("X-Geo-Country", "FR") + .header("X-Geo-Region", "IDF") + .header("CloudFront-Viewer-Country", "DE") + .body(EdgeBody::empty()) + .expect("should build SDK request"); + + // Act. + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy SDK request"); + + // Assert. + assert_eq!( + response.status(), + http::StatusCode::OK, + "should proxy without redirect" + ); + assert_eq!( + stub.recorded_request_uris(), + vec![format!("https://sdk.example.com{path}?v=1")], + "should preserve noncanonical SDK URL" + ); + let headers = stub.recorded_request_headers(); + for (name, expected) in [ + ("x-geo-country", "US"), + ("x-geo-region", "CA"), + ("cloudfront-viewer-country", "US"), + ] { + assert!( + headers[0] + .iter() + .any(|(actual, value)| actual == name && value == expected), + "should derive {name} only from trusted geo for {path}, geo option {enabled}" + ); + } + assert!( + !headers[0] + .iter() + .any(|(name, _)| name.starts_with("fastlygeo-")), + "should not forward raw caller geo headers" + ); + } + } + + #[test] + fn noncanonical_sdk_omits_geo_when_trusted_pair_is_unavailable() { + for (enabled, method, path) in NONCANONICAL_SDK_CASES { + for geo_result in [ + GeoResult::Value(None), + GeoResult::Failure, + GeoResult::Value(Some(geo_info("US", None))), + GeoResult::Value(Some(geo_info("US", Some(" ")))), + GeoResult::Value(Some(geo_info("XX", Some("CA")))), + GeoResult::Value(Some(geo_info("US", Some("GB-LND")))), + ] { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"sdk".to_vec()); + let services = services_with_geo(Arc::clone(&stub), geo_result); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(DidomiIntegrationConfig { + geo_query_parameters: enabled, + ..config(true) + })); + let request = http::Request::builder() + .method(method.clone()) + .uri(format!( + "https://publisher.example/integrations/didomi/consent{path}" + )) + .header("FastlyGeo-CountryCode", "GB") + .header("FastlyGeo-Region", "LND") + .header("X-Geo-Country", "FR") + .header("X-Geo-Region", "IDF") + .header("CloudFront-Viewer-Country", "DE") + .body(EdgeBody::empty()) + .expect("should build SDK request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy SDK request without geo"); + + assert_eq!( + response.status(), + http::StatusCode::OK, + "should tolerate unavailable geo outside enabled GET loaders" + ); + let headers = stub.recorded_request_headers(); + for name in [ + "x-geo-country", + "x-geo-region", + "cloudfront-viewer-country", + "fastlygeo-countrycode", + "fastlygeo-region", + ] { + assert!( + !headers[0].iter().any(|(actual, _)| actual == name), + "should omit {name} without a complete trusted pair for {path}, geo option {enabled}" + ); + } + } + } + } + + #[test] + fn noncanonical_sdk_bypasses_cache_and_strips_response_cache_metadata() { + for (enabled, method, path) in NONCANONICAL_SDK_CASES { + // Even absent geo cannot make a URL eligible for shared caching. + for geo_result in [ + GeoResult::Value(Some(geo_info("US", Some("CA")))), + GeoResult::Value(None), + ] { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"sdk".to_vec(), + vec![ + ("Cache-Control", "public, max-age=3600"), + ("Expires", "Wed, 21 Oct 2037 07:28:00 GMT"), + ("ETag", "\"sdk-response\""), + ("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"), + ("Age", "120"), + ("Surrogate-Control", "max-age=3600"), + ("CDN-Cache-Control", "max-age=3600"), + ("Cloudflare-CDN-Cache-Control", "max-age=3600"), + ("Fastly-Surrogate-Control", "max-age=3600"), + ], + ); + let services = services_with_geo(Arc::clone(&stub), geo_result); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(DidomiIntegrationConfig { + geo_query_parameters: enabled, + ..config(true) + })); + let request = http::Request::builder() + .method(method.clone()) + .uri(format!("https://publisher.example/integrations/didomi/consent{path}?country=GB®ion=LND")) + .header(header::IF_NONE_MATCH, "\"old-geo-response\"") + .header(header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT") + .body(EdgeBody::empty()) + .expect("should build SDK request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy SDK request"); + + assert_eq!( + stub.recorded_cache_intents(), + vec![PlatformCacheIntent::Bypass], + "should bypass SDK cache without canonical geo identity for {path}, geo option {enabled}" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store, private"), + "should prohibit downstream storage" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "should preserve privacy through terminal response hooks" + ); + assert_eq!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("*"), + "should retain SDK CORS" + ); + for name in [ + "expires", + "etag", + "last-modified", + "age", + "surrogate-control", + "cdn-cache-control", + "cloudflare-cdn-cache-control", + "fastly-surrogate-control", + ] { + assert!( + response.headers().get(name).is_none(), + "should remove SDK cache metadata {name}" + ); + } + let headers = stub.recorded_request_headers(); + assert!( + !headers[0].iter().any(|(name, _)| matches!( + name.as_str(), + "if-none-match" | "if-modified-since" + )), + "should not revalidate a previously cached geo response" + ); + } + } + } + + #[test] + fn api_requests_do_not_forward_geo_headers() { + for enabled in [false, true] { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"event".to_vec()); + let services = services_with_geo( + Arc::clone(&stub), + GeoResult::Value(Some(geo_info("US", Some("CA")))), + ); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(DidomiIntegrationConfig { + geo_query_parameters: enabled, + ..config(true) + })); + let request = http::Request::builder() + .uri("https://publisher.example/integrations/didomi/consent/api/events") + .header("FastlyGeo-CountryCode", "GB") + .header("FastlyGeo-Region", "LND") + .header("X-Geo-Country", "FR") + .header("X-Geo-Region", "IDF") + .header("CloudFront-Viewer-Country", "DE") + .body(EdgeBody::empty()) + .expect("should build API request"); + + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy API request"); + + let headers = stub.recorded_request_headers(); + for name in [ + "x-geo-country", + "x-geo-region", + "cloudfront-viewer-country", + "fastlygeo-countrycode", + "fastlygeo-region", + ] { + assert!( + !headers[0].iter().any(|(actual, _)| actual == name), + "should omit API geo header {name}" + ); + } + } + } + #[test] fn enabled_geo_fails_closed_without_complete_geo() { for geo_result in [ diff --git a/docs/guide/integrations/didomi.md b/docs/guide/integrations/didomi.md index a2a64e434b..6a5ec46c6a 100644 --- a/docs/guide/integrations/didomi.md +++ b/docs/guide/integrations/didomi.md @@ -80,8 +80,8 @@ Trusted Server passes the resolved path to its browser bundle through ## Notice-loader geo flow -Geo handling applies only when `geo_query_parameters = true` and the request is -a `GET` whose path after the proxy prefix is exactly +URL canonicalization applies only when `geo_query_parameters = true` and the +request is a `GET` whose path after the proxy prefix is exactly `//loader.js`. For example, a California request to: @@ -133,8 +133,17 @@ eligible loader returns a private, non-storable `503 Service Unavailable` withou contacting Didomi. This includes locations for which Fastly supplies no region. Publishers should verify geo coverage before enabling the option globally. -Other SDK assets and all behavior with `geo_query_parameters = false` retain the -existing proxy flow. +Every SDK request uses trusted platform geo for `X-Geo-Country`, `X-Geo-Region`, +and `CloudFront-Viewer-Country`, regardless of `geo_query_parameters`. Inbound +`FastlyGeo-CountryCode`, `FastlyGeo-Region`, and caller-supplied destination geo +headers are never forwarded as geography. + +Other SDK requests, including loaders with `geo_query_parameters = false`, keep +their incoming path and query. If the trusted lookup fails or the country/region +pair is incomplete or invalid, these requests still proxy but omit all three geo +headers. A country-only lookup also omits the entire group. Cloudflare currently +provides no trusted region, and Axum and Spin provide no platform geo, so these +adapters omit the group. ## Endpoints and caching @@ -144,10 +153,19 @@ All paths under the proxy prefix other than `/api/*` use the SDK origin. Trusted Server forwards the incoming SDK path and query, except for the authoritative notice-loader canonicalization described above. -SDK responses keep Didomi's `Cache-Control`, `Expires`, validators, age, and CDN -cache headers. Shared caches must include the full path and query in their cache -key and honor the geo-less redirect's `private, no-store` policy. Trusted Server -does not replace Didomi's freshness policy with a hard-coded TTL. +Canonical notice-loader responses with `geo_query_parameters = true` keep +Didomi's `Cache-Control`, `Expires`, validators, age, and CDN cache headers. Shared +caches must include the full path and query in their cache key and honor the +geo-less redirect's `private, no-store` policy. Trusted Server does not replace +Didomi's freshness policy with a hard-coded TTL. + +Every other SDK request bypasses the platform outbound cache, including other SDK +assets, POST requests, and loaders with `geo_query_parameters = false`. Their URLs +do not establish a validated trusted geo cache identity, even when they contain +caller-supplied country/region parameters. All of these responses use +`Cache-Control: no-store, private`; expiry, validators, age, and independent CDN +cache directives are removed. This applies even when no trusted geo is available. +Client conditional cache headers are not forwarded to Didomi. ### API @@ -179,6 +197,29 @@ Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS ## Rollout checks +### Migrating from legacy geo headers + +This is a compatibility change for deployments that supply `FastlyGeo-*` request +headers. Those headers no longer influence upstream Didomi geo headers. Use the +runtime's trusted client IP and platform geo service; setting or restoring caller +headers is not a supported fallback. Verify notice selection when trusted geo is +missing, especially on adapters without complete country/region support. Didomi +may use its own fallback when Trusted Server omits geo headers. + +Purge existing SDK entries from the platform outbound cache and any downstream +shared caches before serving traffic with this change. Previously cached browser +responses can remain fresh until their old TTL expires; plan a cache-busting SDK +URL change or an expiry window if immediate migration is required. New response +policies cannot retroactively invalidate fresh browser entries. + +Expect increased origin traffic and latency for noncanonical SDK requests, +including all SDK requests with `geo_query_parameters = false`. Check Didomi +origin capacity and monitor errors during rollout. To retain loader cache reuse +on a supported platform, validate trusted geo coverage before enabling +`geo_query_parameters`; other SDK assets remain non-storable. + +### Enabling canonical notice loaders + Before enabling `geo_query_parameters`: 1. Confirm the loader redirect remains same-origin and is allowed by the site's @@ -187,9 +228,10 @@ Before enabling `geo_query_parameters`: supported traffic. 3. Confirm every shared cache keys SDK objects by the complete path and query. 4. Test at least two locations that require different notices. -5. Verify repeated requests for one country/region reuse the cached SDK response +5. Verify repeated canonical loader requests for one country/region reuse the cached response and retain Didomi's freshness and validator headers. -6. Verify API requests never enter the outbound or downstream cache. +6. Verify API and noncanonical SDK requests never enter the outbound or downstream + cache. 7. Purge loader and API entries cached before this behavior was enabled. 8. Test from egress whose geo can shift between requests (carrier NAT pools, relay-style proxies). The loader canonicalizes with one redirect per stable diff --git a/docs/superpowers/plans/2026-10-19-didomi-trusted-geo.md b/docs/superpowers/plans/2026-10-19-didomi-trusted-geo.md new file mode 100644 index 0000000000..e5090ae845 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-didomi-trusted-geo.md @@ -0,0 +1,74 @@ +# Trusted Didomi Geo Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans for central implementation +> and superpowers:requesting-code-review for independent final reviews. + +**Goal:** Resolve #1151 by removing caller-supplied Didomi geography and protecting +SDK responses whose URLs do not identify trusted geo. + +**Architecture:** Reuse the existing geo normalizer and response privacy helper. +Resolve SDK geo once, preserve canonical-loader behavior, and bypass caching for +every noncanonical request. No new dependencies or adapter API changes. + +**Tech stack:** Rust, RuntimeServices, platform HTTP cache intent, Markdown. + +## Task 1: Regression tests + +File: `crates/trusted-server-core/src/integrations/didomi.rs` test module. + +- [x] Add a request matrix for loader/asset, geo mode on/off, and POST loader. + Assert trusted normalized geo wins over spoofed source/destination headers. +- [x] Add unavailable, invalid, incomplete, and failed geo cases asserting proxy + success without derived or raw geo headers on noncanonical requests. +- [x] Assert noncanonical SDK requests bypass caching, strip all cache metadata, + retain CORS, and exclude client conditional headers from upstream requests. +- [x] Verify API geo isolation and canonical loader cache preservation. +- [x] Run `cargo test -p trusted-server-core didomi` and confirm failures expose + spoofed headers/default caching before changing production code. + +## Task 2: Minimal implementation + +File: `crates/trusted-server-core/src/integrations/didomi.rs`. + +- [x] Resolve SDK geo before loader handling. Preserve loader lookup failure + reasons; tolerate missing geo on other SDK paths by omitting headers. +- [x] Remove `copy_geo_headers`, and set derived headers only from trusted geo. +- [x] Use `canonical_loader.is_some()` to select cache eligibility; otherwise + bypass outbound caching and enforce terminal private response privacy. +- [x] Run focused tests and `cargo fmt --all -- --check`. + +## Task 3: Documentation and verification + +File: `docs/guide/integrations/didomi.md`. + +- [x] Document all-SDK header trust, complete-pair omission, cache policy, purge + requirements, platform limitations, and compatibility/origin traffic impact. +- [x] Run repository CI gates and record any environment or baseline failures. +- [x] Have independent subagents review `git diff main` for correctness, security, + regression coverage, and compatibility. Resolve findings and reverify. +- [x] Keep the new branch/worktree for user handoff; do not merge or publish. + +## Review record + +The independent design review approved the spec and plan without blocking gaps. +Independent security and correctness reviewers then examined the complete diff +against `main` and reported no findings requiring changes. They confirmed the +geo trust boundary, canonical cache eligibility, private fallback responses, and +migration guidance. One optional suggestion was to count API geo lookups in tests; +the production SDK-only lookup condition remains explicit. + +Focused native tests demonstrated three failures before the production fix +(spoofed headers, missing-geo header fallback, and default outbound caching), then +passed all 37 Didomi-related tests after it. The JavaScript suite passed all 1,211 +tests with the pinned Node 24.12.0. All required repository CI gates passed: + +- Rust format; eight adapter/CLI/codegen lint aliases; native build-digest lint. +- Fastly, Fastly reusable sandbox, Axum, Cloudflare, and Spin tests. +- Adapter parity (17 tests) and native build-digest tests (10 tests). +- JavaScript build, tests, and formatting; documentation and Markdown formatting. +- Wasm clock lint and all documentation proposal script checks. + +Fastly tests required access to the macOS certificate keychain, and Axum tests +required local loopback sockets. Their sandbox-only startup/bind failures passed +when rerun with those permissions. The first JavaScript test attempt selected +Node 20 in the nested worktree; the pinned Node 24.12.0 run passed. diff --git a/docs/superpowers/specs/2026-10-19-didomi-trusted-geo-design.md b/docs/superpowers/specs/2026-10-19-didomi-trusted-geo-design.md new file mode 100644 index 0000000000..7b9f8f1333 --- /dev/null +++ b/docs/superpowers/specs/2026-10-19-didomi-trusted-geo-design.md @@ -0,0 +1,59 @@ +# Trusted Didomi SDK geography + +## Problem and scope + +Issue #1151 tracks the legacy SDK fallback that copies caller-controlled +`FastlyGeo-*` headers into Didomi geo headers. PR #1127 protects canonical notice +loaders only. Fix the remaining SDK paths without changing the API URL contract +or extending loader canonicalization to unrelated assets. + +## Approved design + +- Resolve SDK geography from `RuntimeServices.geo()` using trusted client info. + Normalize it with the existing complete country/region validator. Never derive + upstream geography from inbound headers. +- Eligible notice loaders with `geo_query_parameters = true` retain their + canonical redirect, complete-geo requirement, private failure responses, and + canonical URL cache identity. +- Other SDK requests forward the normalized trusted pair when available, or omit + all three derived geo headers on unavailable, failed, incomplete, or invalid + geo. They continue to proxy without adding geo query parameters. +- Only canonical notice loaders retain default outbound caching and upstream + cache metadata. All other SDK requests bypass the outbound cache and enforce + terminal `no-store, private` responses, including removal of validators and + independent CDN cache directives. Keep SDK CORS behavior. +- API requests retain cache bypass, private responses, and no geo lookup/headers. + +## Tradeoffs and compatibility + +Omitting every fallback geo header would lose usable platform geo. Extending +canonicalization to every SDK asset would broaden the URL contract. Reusing the +existing validator and privacy controls is the smallest safe change. + +Deployments relying on inbound headers must move to trusted platform geo. +Country-only platforms omit the full header group. Noncanonical SDK traffic loses +cache reuse and may increase origin traffic. Operators must purge previously +cached SDK responses and validate notice selection and origin capacity. Canonical +loader geo lookup remains fail-closed; this change does not solve geo availability. + +## Acceptance and verification + +Regression tests cover loaders and other SDK assets with geo mode enabled and +disabled, spoofed source and destination geo headers, normalized platform geo, +missing/invalid/error geo, POST loader requests, cache bypass and stripped cache +metadata, preserved canonical caching, and unchanged API isolation. Existing +conditional request headers remain excluded from upstream forwarding. + +Run focused native Didomi tests first, observing new tests fail before the fix. +Then run target-matched adapter tests/lints and repository CI gates. Independent +subagents review the complete diff against `main` for correctness and security. + +## Approval + +The user approved the recommended design in chat on 2026-10-19. + +## Review log + +- Independent design review: approved with no blocking gaps. +- Independent final security and correctness reviews against `main`: no findings + requiring changes; reviewers confirmed the implementation matches this design. From b9d153a87f9c966bd12065a97f20073e76ec20b8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 24 Oct 2026 11:09:18 +0530 Subject: [PATCH 2/2] Preserve Didomi static SDK caching and clarify loader rollout --- CHANGELOG.md | 2 + .../src/integrations/didomi.rs | 132 +++++++++++++++--- docs/guide/integrations/didomi.md | 51 +++++-- .../plans/2026-10-19-didomi-trusted-geo.md | 82 +++++++---- .../2026-10-19-didomi-trusted-geo-design.md | 42 +++--- 5 files changed, 225 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f691c6a005..40c61f8a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Didomi SDK requests now derive `X-Geo-Country`, `X-Geo-Region`, and `CloudFront-Viewer-Country` only from trusted platform geo (#1151). Caller-supplied `FastlyGeo-CountryCode` and `FastlyGeo-Region` headers previously let callers choose the geography sent to Didomi. **Breaking** for deployments that injected those headers: configure CDN-fronted client IP instead. Without a complete trusted country/region pair (currently always on Cloudflare, Axum, and Spin), the geo header group is omitted. Noncanonical loaders and SDK POSTs bypass the outbound cache and return `no-store, private`; other SDK GETs retain Didomi's cache policy. Purge cached SDK entries before rollout. Geo-less loaders risk Didomi throttling and disablement; production Fastly deployments should validate geo coverage and enable `geo_query_parameters = true`. Other adapters currently have no compliant loader-caching mode through this proxy; use direct SDK delivery instead. + - `/first-party/sign` now rejects valid targets outside `proxy.allowed_domains` before minting a proxy token. The creative runtime keeps image and iframe assignments blocked after this `403` policy response instead of loading the rejected URL directly; fetch-time checks still cover the initial target and every redirect. - Reserved the complete admin namespace at the publisher-fallback boundary. Percent-encoded separators (`/_ts/admin%2Fec`, `%2f`, and double-encoded forms) matched the `^/_ts/admin` Basic-auth handler but escaped the literal-slash namespace check, so an authenticated request fell through to publisher fallback and forwarded its `Authorization` header and body to the publisher origin. The reservation now spans the whole `/_ts/admin` prefix plus the retired `/admin/keys` aliases — including trailing, descendant, and encoded-separator forms — evaluated on the raw path and on each of its bounded percent-decodings, so multi-encoded separators such as `/admin%252Fkeys/rotate` cannot survive to fallback for a proxy or origin to decode again, and applies to every adapter. - Validate synthetic ID format on inbound values from the `x-synthetic-id` header and `synthetic_id` cookie; values that do not match the expected format (`64-hex-hmac.6-alphanumeric-suffix`) are discarded and a fresh ID is generated rather than forwarded to response headers, cookies, or third-party APIs diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index 8a24ead128..a92c643aa3 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -416,23 +416,22 @@ impl IntegrationProxy for DidomiIntegration { let consent_path = path.strip_prefix(&prefix).unwrap_or(&path); let backend = self.backend_for_path(consent_path); let trusted_geo = if matches!(backend, DidomiBackend::Sdk) { - match services.geo().lookup(services.client_info().client_ip) { - Ok(Some(geo)) => normalize_didomi_geo(&geo) - .map(Some) - .map_err(DidomiGeoError::reason), - Ok(None) => Err("geo_unavailable"), - Err(_) => Err("lookup_failed"), - } + Some( + match services.geo().lookup(services.client_info().client_ip) { + Ok(Some(geo)) => normalize_didomi_geo(&geo).map_err(DidomiGeoError::reason), + Ok(None) => Err("geo_unavailable"), + Err(_) => Err("lookup_failed"), + }, + ) } else { - Ok(None) + None }; let canonical_loader = if self.config.geo_query_parameters - && matches!(backend, DidomiBackend::Sdk) && is_notice_loader(&parts.method, consent_path) + && let Some(geo) = &trusted_geo { - let geo = match &trusted_geo { - Ok(Some(geo)) => geo, - Ok(None) => return Ok(Self::geo_failure_response("geo_unavailable")), + let geo = match geo { + Ok(geo) => geo, Err(reason) => return Ok(Self::geo_failure_response(reason)), }; let canonical = canonical_loader_url(&path, parts.uri.query(), geo); @@ -445,6 +444,9 @@ impl IntegrationProxy for DidomiIntegration { } Some(canonical) } else { + if let Some(Err(reason)) = &trusted_geo { + log::debug!("Didomi SDK request omits geo headers: reason={reason}"); + } None }; let base_origin = match backend { @@ -480,12 +482,17 @@ impl IntegrationProxy for DidomiIntegration { services.client_info().client_ip, &parts.headers, proxy_req.headers_mut(), - trusted_geo.as_ref().ok().and_then(Option::as_ref), + trusted_geo.as_ref().and_then(|geo| geo.as_ref().ok()), ); + // Dynamic loaders need a validated geo identity for shared caching. + // Other SDK GETs follow Didomi's origin cache policy for static assets. + let shared_cacheable = canonical_loader.is_some() + || (matches!(backend, DidomiBackend::Sdk) + && parts.method == Method::GET + && !consent_path.ends_with("/loader.js")); let platform_request = PlatformHttpRequest::new(proxy_req, backend_name); - // Only validated canonical loader URLs identify the trusted geo in cache keys. - let platform_request = if canonical_loader.is_some() { + let platform_request = if shared_cacheable { platform_request } else { platform_request.with_cache_bypass() @@ -499,7 +506,7 @@ impl IntegrationProxy for DidomiIntegration { if matches!(backend, DidomiBackend::Sdk) { Self::add_cors_headers(&mut response.response); } - if canonical_loader.is_none() { + if !shared_cacheable { crate::response_privacy::enforce_terminal_private_cache_privacy(&mut response.response); } @@ -553,6 +560,7 @@ mod tests { use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use http::Method; + #[derive(Clone)] enum GeoResult { Value(Option), Failure, @@ -916,7 +924,7 @@ mod tests { } #[test] - fn disabled_geo_preserves_existing_loader_behavior() { + fn disabled_geo_proxies_loader_without_geo_query() { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"loader".to_vec()); let services = services_with_geo( @@ -1100,9 +1108,18 @@ mod tests { } } + const UNCACHEABLE_SDK_CASES: [(bool, Method, &str); 6] = [ + (false, Method::GET, "/key/loader.js"), + (true, Method::GET, "/nested/key/loader.js"), + (true, Method::GET, "/loader.js"), + (false, Method::POST, "/key/loader.js"), + (true, Method::POST, "/key/loader.js"), + (true, Method::POST, "/sdk/v1/core.js"), + ]; + #[test] - fn noncanonical_sdk_bypasses_cache_and_strips_response_cache_metadata() { - for (enabled, method, path) in NONCANONICAL_SDK_CASES { + fn noncanonical_loaders_and_sdk_posts_bypass_cache_and_strip_metadata() { + for (enabled, method, path) in UNCACHEABLE_SDK_CASES { // Even absent geo cannot make a URL eligible for shared caching. for geo_result in [ GeoResult::Value(Some(geo_info("US", Some("CA")))), @@ -1197,6 +1214,81 @@ mod tests { } } + #[test] + fn static_sdk_assets_keep_origin_cache_policy() { + for enabled in [false, true] { + for geo_result in [ + GeoResult::Value(Some(geo_info("US", Some("CA")))), + GeoResult::Value(None), + ] { + for path in ["/sdk/v1/core.js", "/sdk/v1/ui.js", "/sdk/v1/iab-texts.js"] { + let stub = Arc::new(StubHttpClient::new()); + let cache_headers = vec![ + ("Cache-Control", "public, max-age=31536000"), + ("Expires", "Wed, 21 Oct 2037 07:28:00 GMT"), + ("ETag", "\"sdk-chunk\""), + ("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"), + ("Age", "120"), + ("Surrogate-Control", "max-age=31536000"), + ("CDN-Cache-Control", "max-age=31536000"), + ("Cloudflare-CDN-Cache-Control", "max-age=31536000"), + ("Fastly-Surrogate-Control", "max-age=31536000"), + ]; + stub.push_response_with_headers(200, b"sdk".to_vec(), cache_headers.clone()); + let services = services_with_geo(Arc::clone(&stub), geo_result.clone()); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(DidomiIntegrationConfig { + geo_query_parameters: enabled, + ..config(true) + })); + let request = http::Request::builder() + .method(Method::GET) + .uri(format!( + "https://publisher.example/integrations/didomi/consent{path}?v=1" + )) + .body(EdgeBody::empty()) + .expect("should build SDK asset request"); + + let response = futures::executor::block_on( + integration.handle(&settings, &services, request), + ) + .expect("should proxy SDK asset"); + + assert_eq!( + stub.recorded_cache_intents(), + vec![PlatformCacheIntent::Default], + "should follow origin cache policy for {path}, geo option {enabled}" + ); + for (name, expected) in cache_headers { + assert_eq!( + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()), + Some(expected), + "should preserve {name} for {path}, geo option {enabled}" + ); + } + assert!( + response + .extensions() + .get::() + .is_none(), + "should allow origin cache policy through terminal response hooks" + ); + assert_eq!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("*"), + "should retain SDK CORS for static assets" + ); + } + } + } + } + #[test] fn api_requests_do_not_forward_geo_headers() { for enabled in [false, true] { @@ -1348,7 +1440,7 @@ mod tests { } #[test] - fn sdk_responses_preserve_origin_cache_metadata() { + fn canonical_loader_responses_preserve_origin_cache_metadata() { let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( 200, diff --git a/docs/guide/integrations/didomi.md b/docs/guide/integrations/didomi.md index 6a5ec46c6a..9e1bb8f81c 100644 --- a/docs/guide/integrations/didomi.md +++ b/docs/guide/integrations/didomi.md @@ -159,10 +159,16 @@ caches must include the full path and query in their cache key and honor the geo-less redirect's `private, no-store` policy. Trusted Server does not replace Didomi's freshness policy with a hard-coded TTL. -Every other SDK request bypasses the platform outbound cache, including other SDK -assets, POST requests, and loaders with `geo_query_parameters = false`. Their URLs -do not establish a validated trusted geo cache identity, even when they contain -caller-supplied country/region parameters. All of these responses use +Other SDK GETs, including static core, UI, and translation files, retain default +outbound caching and all origin cache metadata regardless of geo availability or +`geo_query_parameters`. Didomi's cache headers control their freshness in the +platform cache and browser; Trusted Server does not impose a TTL. + +Noncanonical loaders (any SDK path ending in `/loader.js` without a validated +canonical URL) and SDK POSTs bypass the platform outbound cache. This includes +loaders with `geo_query_parameters = false`, root `/loader.js`, and nested loader +paths. Their URLs do not establish a validated trusted geo cache identity, even +when they contain caller-supplied country/region parameters. These responses use `Cache-Control: no-store, private`; expiry, validators, age, and independent CDN cache directives are removed. This applies even when no trusted geo is available. Client conditional cache headers are not forwarded to Didomi. @@ -202,9 +208,12 @@ Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS This is a compatibility change for deployments that supply `FastlyGeo-*` request headers. Those headers no longer influence upstream Didomi geo headers. Use the runtime's trusted client IP and platform geo service; setting or restoring caller -headers is not a supported fallback. Verify notice selection when trusted geo is -missing, especially on adapters without complete country/region support. Didomi -may use its own fallback when Trusted Server omits geo headers. +headers is not a supported fallback. If Trusted Server runs behind another CDN or +proxy, configure [CDN-fronted client IP](/guide/fastly#cdn-fronted-client-ip); +otherwise platform geo resolves the proxy's address rather than the visitor's. +Verify notice selection when trusted geo is missing, especially on adapters +without complete country/region support. Didomi may use its own fallback when +Trusted Server omits geo headers. Purge existing SDK entries from the platform outbound cache and any downstream shared caches before serving traffic with this change. Previously cached browser @@ -212,11 +221,25 @@ responses can remain fresh until their old TTL expires; plan a cache-busting SDK URL change or an expiry window if immediate migration is required. New response policies cannot retroactively invalidate fresh browser entries. -Expect increased origin traffic and latency for noncanonical SDK requests, -including all SDK requests with `geo_query_parameters = false`. Check Didomi -origin capacity and monitor errors during rollout. To retain loader cache reuse -on a supported platform, validate trusted geo coverage before enabling -`geo_query_parameters`; other SDK assets remain non-storable. +Noncanonical loaders bypass caching and contact Didomi on every request, including +loaders with the default `geo_query_parameters = false`. Didomi's reverse-proxy +requirements linked above prohibit querying its CDN on every end-user request and +warn that excessive queries can cause automatic throttling and disablement without +warning. This +remaining loader bypass is a security trade-off, not a production caching mode +that meets those requirements. + +For Fastly production deployments, validate complete trusted country/region +coverage and enable `geo_query_parameters = true` so canonical notice loaders can +reuse the outbound cache under Didomi's freshness policy. Ensure the SDK uses the +supported `//loader.js` path. Cloudflare, Axum, and Spin currently +lack complete trusted geo and have no compliant loader-caching mode through this +proxy. Do not deploy the geo-less loader proxy as a production workaround on those +adapters. To use Didomi's supported direct SDK delivery instead, set +`[integrations.didomi].enabled = false` (or remove the integration block) and +restore the direct Didomi embed and SDK paths; the enabled integration rewrites +`didomiConfig.sdkPath` through the proxy. Other SDK GETs, including static assets, +continue to honor Didomi's cache policy. ### Enabling canonical notice loaders @@ -230,8 +253,8 @@ Before enabling `geo_query_parameters`: 4. Test at least two locations that require different notices. 5. Verify repeated canonical loader requests for one country/region reuse the cached response and retain Didomi's freshness and validator headers. -6. Verify API and noncanonical SDK requests never enter the outbound or downstream - cache. +6. Verify API requests, noncanonical loaders, and SDK POSTs never enter the + outbound or downstream cache; other SDK GETs should retain origin cache policy. 7. Purge loader and API entries cached before this behavior was enabled. 8. Test from egress whose geo can shift between requests (carrier NAT pools, relay-style proxies). The loader canonicalizes with one redirect per stable diff --git a/docs/superpowers/plans/2026-10-19-didomi-trusted-geo.md b/docs/superpowers/plans/2026-10-19-didomi-trusted-geo.md index e5090ae845..95936ffaf3 100644 --- a/docs/superpowers/plans/2026-10-19-didomi-trusted-geo.md +++ b/docs/superpowers/plans/2026-10-19-didomi-trusted-geo.md @@ -8,7 +8,8 @@ SDK responses whose URLs do not identify trusted geo. **Architecture:** Reuse the existing geo normalizer and response privacy helper. Resolve SDK geo once, preserve canonical-loader behavior, and bypass caching for -every noncanonical request. No new dependencies or adapter API changes. +noncanonical loaders and SDK POSTs. Other SDK GETs follow origin cache policy. +No new dependencies or adapter API changes. **Tech stack:** Rust, RuntimeServices, platform HTTP cache intent, Markdown. @@ -20,8 +21,8 @@ File: `crates/trusted-server-core/src/integrations/didomi.rs` test module. Assert trusted normalized geo wins over spoofed source/destination headers. - [x] Add unavailable, invalid, incomplete, and failed geo cases asserting proxy success without derived or raw geo headers on noncanonical requests. -- [x] Assert noncanonical SDK requests bypass caching, strip all cache metadata, - retain CORS, and exclude client conditional headers from upstream requests. +- [x] Assert noncanonical loaders and SDK POSTs bypass caching, strip all cache + metadata, retain CORS, and exclude client conditional headers upstream. - [x] Verify API geo isolation and canonical loader cache preservation. - [x] Run `cargo test -p trusted-server-core didomi` and confirm failures expose spoofed headers/default caching before changing production code. @@ -33,8 +34,8 @@ File: `crates/trusted-server-core/src/integrations/didomi.rs`. - [x] Resolve SDK geo before loader handling. Preserve loader lookup failure reasons; tolerate missing geo on other SDK paths by omitting headers. - [x] Remove `copy_geo_headers`, and set derived headers only from trusted geo. -- [x] Use `canonical_loader.is_some()` to select cache eligibility; otherwise - bypass outbound caching and enforce terminal private response privacy. +- [x] Retain origin caching for canonical loaders and other SDK GETs except paths + ending `/loader.js`; bypass other requests and enforce terminal privacy. - [x] Run focused tests and `cargo fmt --all -- --check`. ## Task 3: Documentation and verification @@ -46,29 +47,48 @@ File: `docs/guide/integrations/didomi.md`. - [x] Run repository CI gates and record any environment or baseline failures. - [x] Have independent subagents review `git diff main` for correctness, security, regression coverage, and compatibility. Resolve findings and reverify. -- [x] Keep the new branch/worktree for user handoff; do not merge or publish. - -## Review record - -The independent design review approved the spec and plan without blocking gaps. -Independent security and correctness reviewers then examined the complete diff -against `main` and reported no findings requiring changes. They confirmed the -geo trust boundary, canonical cache eligibility, private fallback responses, and -migration guidance. One optional suggestion was to count API geo lookups in tests; -the production SDK-only lookup condition remains explicit. - -Focused native tests demonstrated three failures before the production fix -(spoofed headers, missing-geo header fallback, and default outbound caching), then -passed all 37 Didomi-related tests after it. The JavaScript suite passed all 1,211 -tests with the pinned Node 24.12.0. All required repository CI gates passed: - -- Rust format; eight adapter/CLI/codegen lint aliases; native build-digest lint. -- Fastly, Fastly reusable sandbox, Axum, Cloudflare, and Spin tests. -- Adapter parity (17 tests) and native build-digest tests (10 tests). -- JavaScript build, tests, and formatting; documentation and Markdown formatting. -- Wasm clock lint and all documentation proposal script checks. - -Fastly tests required access to the macOS certificate keychain, and Axum tests -required local loopback sockets. Their sandbox-only startup/bind failures passed -when rerun with those permissions. The first JavaScript test attempt selected -Node 20 in the nested worktree; the pinned Node 24.12.0 run passed. + +## PR review follow-up + +The reviews on PR #1414 identified excessive cache bypass for static SDK assets. +Keep the loader URL contract and bypass policy; document the remaining throttling +risk and recommend canonical loaders on Fastly after validating geo coverage. + +- [x] Add regression coverage for static core/UI/text GETs preserving all origin + cache metadata with geo enabled/disabled and trusted geo present/absent. + Run `cargo test -p trusted-server-core didomi` and observe the new failure. +- [x] Separate bypass cases from the geo-header matrix, including root and nested + loader paths and SDK POSTs. Keep spoofed-header coverage for static assets. +- [x] Restore default outbound caching and origin response metadata for SDK GETs + except `/loader.js` suffixes; preserve canonical-loader and API behavior. +- [x] Represent SDK geo as `Some(Ok | Err)` and API geo as `None`, eliminating the + unreachable success-without-geo arm. Log omitted SDK geo at debug level. +- [x] Rename stale tests, update the guide/spec, add the security release note, + link trusted client-IP setup, and remove inaccessible chat/session notes. +- [x] Run focused tests, target-matched adapter tests and lint, and repository CI + gates. Review the complete PR diff before replying to and resolving threads. + +## Verification results + +The static SDK cache regression failed with `Bypass` before the cache-policy fix; +all 38 focused Didomi tests pass afterward. Independent review of the complete PR +diff found two guide inconsistencies (cacheable SDK GETs in the rollout checklist +and disabling SDK path rewriting for direct delivery); both were corrected. The +final review found no further confirmed runtime or documentation issue. + +Passed checks: + +- Rust format and all eight adapter/CLI/codegen clippy aliases. +- Fastly, reusable-sandbox, Axum, Cloudflare, and Spin tests. +- Adapter parity (17 tests), native build-digest tests (10 tests), and its lint. +- Host CLI tests, including the enabled browser fixtures in `scripts/test-cli.sh`. +- JS build (13 modules), 1,211 tests across 50 files, JS/docs/Markdown formatting. +- WASM clock lint and documentation proposal shellcheck, Node tests, and harness. +- Added-line domain lint; the optional full-repository audit reports pre-existing + violations and this follow-up adds none. + +Fastly tests used Viceroy 0.21.0; the installed 0.17.0 cannot load a required +Fastly import. Runtime tests needed macOS keychain/loopback access. JS checks used +Node 24.12.0. The release-producer checks listed in the current workflow +instructions cannot run because this branch has no `scripts/release/` directory. +No live Fastly deployment or CDN smoke test was performed. diff --git a/docs/superpowers/specs/2026-10-19-didomi-trusted-geo-design.md b/docs/superpowers/specs/2026-10-19-didomi-trusted-geo-design.md index 7b9f8f1333..709eea1302 100644 --- a/docs/superpowers/specs/2026-10-19-didomi-trusted-geo-design.md +++ b/docs/superpowers/specs/2026-10-19-didomi-trusted-geo-design.md @@ -7,7 +7,7 @@ Issue #1151 tracks the legacy SDK fallback that copies caller-controlled loaders only. Fix the remaining SDK paths without changing the API URL contract or extending loader canonicalization to unrelated assets. -## Approved design +## Design - Resolve SDK geography from `RuntimeServices.geo()` using trusted client info. Normalize it with the existing complete country/region validator. Never derive @@ -18,9 +18,10 @@ or extending loader canonicalization to unrelated assets. - Other SDK requests forward the normalized trusted pair when available, or omit all three derived geo headers on unavailable, failed, incomplete, or invalid geo. They continue to proxy without adding geo query parameters. -- Only canonical notice loaders retain default outbound caching and upstream - cache metadata. All other SDK requests bypass the outbound cache and enforce - terminal `no-store, private` responses, including removal of validators and +- Canonical notice loaders and other SDK GETs except paths ending `/loader.js` + retain default outbound caching and all origin cache metadata. Noncanonical + loaders (including root and nested paths) and SDK POSTs bypass the outbound cache + and enforce terminal `no-store, private`, including removal of validators and independent CDN cache directives. Keep SDK CORS behavior. - API requests retain cache bypass, private responses, and no geo lookup/headers. @@ -31,29 +32,32 @@ canonicalization to every SDK asset would broaden the URL contract. Reusing the existing validator and privacy controls is the smallest safe change. Deployments relying on inbound headers must move to trusted platform geo. -Country-only platforms omit the full header group. Noncanonical SDK traffic loses -cache reuse and may increase origin traffic. Operators must purge previously -cached SDK responses and validate notice selection and origin capacity. Canonical -loader geo lookup remains fail-closed; this change does not solve geo availability. +Country-only platforms omit the full header group. Noncanonical loaders lose cache +reuse and query Didomi on every request, risking automatic throttling and +proxy disablement under Didomi's reverse-proxy requirements. Fastly production +operators should validate geo coverage and enable canonical loader URLs. Other +adapters currently have no compliant loader-caching mode through this proxy and +should use direct SDK delivery. Other SDK GETs follow origin cache policy. +Operators must purge previously cached SDK responses and validate notice selection. +Canonical loader geo lookup remains fail-closed; this change does not solve geo +availability. ## Acceptance and verification Regression tests cover loaders and other SDK assets with geo mode enabled and disabled, spoofed source and destination geo headers, normalized platform geo, -missing/invalid/error geo, POST loader requests, cache bypass and stripped cache -metadata, preserved canonical caching, and unchanged API isolation. Existing -conditional request headers remain excluded from upstream forwarding. +missing/invalid/error geo, POST loader/asset requests, root/nested loader cache +bypass and stripped metadata, preserved static/canonical caching, and unchanged +API isolation. Existing conditional request headers remain excluded from upstream +forwarding. Run focused native Didomi tests first, observing new tests fail before the fix. Then run target-matched adapter tests/lints and repository CI gates. Independent subagents review the complete diff against `main` for correctness and security. -## Approval +## Review references -The user approved the recommended design in chat on 2026-10-19. - -## Review log - -- Independent design review: approved with no blocking gaps. -- Independent final security and correctness reviews against `main`: no findings - requiring changes; reviewers confirmed the implementation matches this design. +PR #1414 reviews identified excessive cache bypass for static SDK assets and the +remaining geo-less loader throttling trade-off. This design retains loader bypass +while restoring origin caching for other SDK GETs and documenting the production +constraints.