Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion crates/trusted-server-core/src/integrations/didomi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ impl DidomiIntegration {
);
}

// `Authorization` is intentionally NOT forwarded: it carries the
Comment thread
aram356 marked this conversation as resolved.
// 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,
Expand All @@ -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());
Expand Down Expand Up @@ -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();
Expand Down
107 changes: 74 additions & 33 deletions crates/trusted-server-core/src/integrations/lockr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -248,11 +247,19 @@ impl LockrIntegration {
from: &HeaderMap<HeaderValue>,
to: &mut HeaderMap<HeaderValue>,
) -> Result<(), Report<TrustedServerError>> {
// 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,
];
Expand All @@ -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)
Expand All @@ -292,34 +296,6 @@ impl LockrIntegration {
Ok(())
}

fn copy_cookie_header(
&self,
from: &HeaderMap<HeaderValue>,
to: &mut HeaderMap<HeaderValue>,
) -> Result<(), Report<TrustedServerError>> {
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,
Expand Down Expand Up @@ -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<dyn crate::platform::PlatformHttpClient>
);
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 = [
Expand Down
56 changes: 55 additions & 1 deletion crates/trusted-server-core/src/integrations/permutive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,14 @@ impl PermutiveIntegration {

/// Copy relevant request headers for proxying.
fn copy_request_headers(&self, from: &HeaderMap<HeaderValue>, to: &mut HeaderMap<HeaderValue>) {
// `Authorization` is intentionally NOT forwarded: it carries the
Comment thread
aram356 marked this conversation as resolved.
// 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,
];
Expand Down Expand Up @@ -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<dyn crate::platform::PlatformHttpClient>
);
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)"
);
}
}
32 changes: 32 additions & 0 deletions crates/trusted-server-core/src/platform/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
responses: Mutex<VecDeque<StubHttpResponse>>,
Expand All @@ -228,6 +231,8 @@ pub(crate) struct StubHttpClient {
stream_response_flags: Mutex<Vec<bool>>,
request_methods: Mutex<Vec<String>>,
request_uris: Mutex<Vec<String>>,
// Outgoing request bodies captured per send call, collected to bytes.
request_bodies: Mutex<Vec<Vec<u8>>>,
}

struct StubHttpResponse {
Expand All @@ -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()),
}
}

Expand Down Expand Up @@ -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<Vec<u8>> {
self.request_bodies
.lock()
.expect("should lock request bodies")
.clone()
}
}

// ?Send matches PlatformHttpClient. See http.rs for the full rationale.
Expand Down Expand Up @@ -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
Comment thread
aram356 marked this conversation as resolved.
.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()
Expand Down
Loading