diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index c3655be9..95558f17 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -164,6 +164,10 @@ impl DidomiIntegration { ); } + // `Authorization` is intentionally NOT forwarded: it carries the + // publisher site's own credential (e.g. staging basic-auth), which would + // leak to the third-party upstream and can break APIs that reject an + // unexpected `Authorization` header. for header_name in [ header::ACCEPT, header::ACCEPT_LANGUAGE, @@ -172,7 +176,6 @@ impl DidomiIntegration { header::USER_AGENT, header::REFERER, header::ORIGIN, - header::AUTHORIZATION, ] { if let Some(value) = original_headers.get(&header_name) { proxy_headers.insert(header_name, value.clone()); @@ -447,6 +450,46 @@ mod tests { ); } + #[test] + fn copy_headers_strips_authorization() { + // Security regression guard: the publisher's Authorization header must + // not be forwarded to the Didomi upstream (credential leak). + let integration = DidomiIntegration::new(Arc::new(config(true))); + let backend = DidomiBackend::Api; + let original_req = http::Request::builder() + .method(Method::POST) + .uri("https://example.com/test") + .header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz") + .header(header::USER_AGENT, "test-agent") + .body(EdgeBody::empty()) + .expect("should build original request"); + let mut proxy_req = http::Request::builder() + .method(Method::POST) + .uri("https://api.privacy-center.org/test") + .body(EdgeBody::empty()) + .expect("should build proxy request"); + + integration.copy_headers( + &backend, + None, + original_req.headers(), + proxy_req.headers_mut(), + ); + + assert!( + proxy_req.headers().get(header::AUTHORIZATION).is_none(), + "should NOT forward the publisher's Authorization header to Didomi" + ); + assert_eq!( + proxy_req + .headers() + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()), + Some("test-agent"), + "should still forward required headers (user-agent)" + ); + } + #[test] fn registers_custom_proxy_path() { let mut settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 010a0e6a..687f02a2 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -18,7 +18,6 @@ use serde::Deserialize; use validator::Validate; use crate::constants::INTERNAL_HEADERS; -use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; use crate::error::TrustedServerError; use crate::integrations::{ collect_body_bounded, collect_response_bounded, ensure_integration_backend, @@ -248,11 +247,19 @@ impl LockrIntegration { from: &HeaderMap, to: &mut HeaderMap, ) -> Result<(), Report> { + // NOTE: `Authorization` and `Cookie` are intentionally NOT forwarded. + // Under the first-party proxy the browser attaches the publisher's own + // credentials to `/integrations/lockr/api/...` — `Authorization` (e.g. + // staging basic-auth) and every publisher session/auth cookie. Both + // would leak to the third-party upstream, and the Lockr API rejects an + // unexpected `Authorization` with `{"code":400,"message":"Invalid + // request"}`. The SDK already passes the identity cookie data it needs + // in the request body (`firstPartyCookies`), so no `Cookie` header is + // required upstream. let headers_to_copy = [ header::CONTENT_TYPE, header::ACCEPT, header::USER_AGENT, - header::AUTHORIZATION, header::ACCEPT_LANGUAGE, header::ACCEPT_ENCODING, ]; @@ -263,9 +270,6 @@ impl LockrIntegration { } } - // Always strip consent cookies — consent travels through the OpenRTB body - self.copy_cookie_header(from, to)?; - // Use origin override if configured, otherwise forward original let origin = self.config.origin_override.as_deref().or_else(|| { from.get(header::ORIGIN) @@ -292,34 +296,6 @@ impl LockrIntegration { Ok(()) } - fn copy_cookie_header( - &self, - from: &HeaderMap, - to: &mut HeaderMap, - ) -> Result<(), Report> { - let Some(cookie_value) = from.get(header::COOKIE) else { - return Ok(()); - }; - - match cookie_value.to_str() { - Ok(value) => { - let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); - if stripped.is_empty() { - return Ok(()); - } - - let cookie_header = HeaderValue::from_str(&stripped) - .change_context(Self::error("Failed to rebuild stripped cookie header"))?; - to.insert(header::COOKIE, cookie_header); - } - Err(_) => { - to.insert(header::COOKIE, cookie_value.clone()); - } - } - - Ok(()) - } - fn backend_name_for_url( services: &RuntimeServices, target_url: &str, @@ -601,6 +577,71 @@ mod tests { ); } + #[test] + fn lockr_proxy_forwards_body_and_strips_publisher_credentials() { + // Regression guard for the upstream-rejection / credential-leak causes: + // 1. The POST body (and content-type) must be forwarded, otherwise the + // Lockr API returns `{"code":400,"message":"Invalid request"}`. + // 2. The publisher's `Authorization` header (e.g. site basic-auth) must + // NOT be forwarded — the Lockr API rejects it with the same 400, and + // forwarding it would leak the publisher credential to a third party. + // 3. The publisher's `Cookie` header (session/auth cookies the browser + // attaches to the first-party route) must NOT be forwarded either. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"success":true,"data":{}}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let integration = LockrIntegration::new(test_config()); + + let payload = br#"{"appID":"test-app-id"}"#; + let req = http::Request::builder() + .method(HttpMethod::POST) + .uri("https://publisher.example/integrations/lockr/api/publisher/app/v2/identityLockr/settings") + .header(header::CONTENT_TYPE, "application/json;charset=UTF-8") + .header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz") + .header(header::COOKIE, "session_id=secret; euconsent-v2=tcf") + .body(EdgeBody::from(payload.to_vec())) + .expect("should build request"); + + let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + .expect("should proxy request"); + assert_eq!(response.status(), http::StatusCode::OK, "should return OK"); + + let bodies = stub.recorded_request_bodies(); + assert_eq!( + bodies.len(), + 1, + "should forward exactly one upstream request" + ); + assert_eq!( + bodies[0], payload, + "should forward the POST body unchanged to the Lockr API" + ); + + let headers = stub.recorded_request_headers(); + assert!( + headers[0] + .iter() + .any(|(name, value)| name == "content-type" + && value == "application/json;charset=UTF-8"), + "should forward the content-type header to the Lockr API" + ); + assert!( + !headers[0] + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")), + "should NOT forward the publisher's Authorization header to the Lockr API" + ); + assert!( + !headers[0] + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("cookie")), + "should NOT forward the publisher's Cookie header to the Lockr API" + ); + } + #[test] fn test_api_path_extraction_preserves_casing() { let test_cases = [ diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index 60983f7e..3721207f 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -257,11 +257,14 @@ impl PermutiveIntegration { /// Copy relevant request headers for proxying. fn copy_request_headers(&self, from: &HeaderMap, to: &mut HeaderMap) { + // `Authorization` is intentionally NOT forwarded: it carries the + // publisher site's own credential (e.g. staging basic-auth), which would + // leak to the third-party upstream and can break APIs that reject an + // unexpected `Authorization` header. let headers_to_copy = [ header::CONTENT_TYPE, header::ACCEPT, header::USER_AGENT, - header::AUTHORIZATION, header::ACCEPT_LANGUAGE, header::ACCEPT_ENCODING, ]; @@ -660,4 +663,55 @@ mod tests { "should route outbound request through PlatformHttpClient" ); } + + #[test] + fn permutive_proxy_strips_authorization() { + // Security regression guard: the publisher's Authorization header must + // not be forwarded to the Permutive upstream (credential leak). + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let integration = PermutiveIntegration::new(PermutiveConfig { + enabled: true, + organization_id: "myorg".to_string(), + workspace_id: "workspace-123".to_string(), + project_id: String::new(), + api_endpoint: default_api_endpoint(), + secure_signals_endpoint: default_secure_signals_endpoint(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + }); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://publisher.example/integrations/permutive/api/v2.0/events") + .header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz") + .header(header::USER_AGENT, "test-agent") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + .expect("should proxy request"); + assert_eq!( + response.status(), + http::StatusCode::OK, + "should return stubbed response" + ); + + let headers = stub.recorded_request_headers(); + assert!( + !headers[0] + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")), + "should NOT forward the publisher's Authorization header to Permutive" + ); + assert!( + headers[0] + .iter() + .any(|(name, value)| name == "user-agent" && value == "test-agent"), + "should still forward required headers (user-agent)" + ); + } } diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index ba3beaae..032859b8 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -214,6 +214,9 @@ struct StubPendingResponse { /// exercising the code under test, then inspect /// [`recorded_backend_names`](Self::recorded_backend_names) to assert call /// sites. +/// Upper bound on the request body bytes captured per `send` call. +const MAX_RECORDED_BODY_BYTES: usize = 64 * 1024 * 1024; + pub(crate) struct StubHttpClient { calls: Mutex>, responses: Mutex>, @@ -228,6 +231,8 @@ pub(crate) struct StubHttpClient { stream_response_flags: Mutex>, request_methods: Mutex>, request_uris: Mutex>, + // Outgoing request bodies captured per send call, collected to bytes. + request_bodies: Mutex>>, } struct StubHttpResponse { @@ -248,6 +253,7 @@ impl StubHttpClient { stream_response_flags: Mutex::new(Vec::new()), request_methods: Mutex::new(Vec::new()), request_uris: Mutex::new(Vec::new()), + request_bodies: Mutex::new(Vec::new()), } } @@ -339,6 +345,17 @@ impl StubHttpClient { .expect("should lock request URIs") .clone() } + + /// Return request bodies captured per `send` call, in order. + /// + /// Each entry is the outgoing request body collected to bytes. Bodies are + /// only captured by [`send`](PlatformHttpClient::send). + pub fn recorded_request_bodies(&self) -> Vec> { + self.request_bodies + .lock() + .expect("should lock request bodies") + .clone() + } } // ?Send matches PlatformHttpClient. See http.rs for the full rationale. @@ -391,6 +408,21 @@ impl PlatformHttpClient for StubHttpClient { .expect("should lock request_headers") .push(headers); + // Capture the outgoing request body so tests can assert it is forwarded. + // Propagate collection failures instead of recording an empty body, so + // tests cannot mistake a capture failure for an intentionally empty body. + let (_, body) = request.request.into_parts(); + let body_bytes = body + .into_bytes_bounded(MAX_RECORDED_BODY_BYTES) + .await + .change_context(PlatformError::HttpClient) + .attach("failed to capture StubHttpClient request body")? + .to_vec(); + self.request_bodies + .lock() + .expect("should lock request bodies") + .push(body_bytes); + let response = self .responses .lock()