From 51f6fc71c0d7899c1259bd4040b1c34b0fe45f03 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 6 Feb 2026 18:24:23 +0530 Subject: [PATCH 1/2] Set x-synthetic-id header on integration routes Integration proxy responses were missing the x-synthetic-id header because handle_proxy dispatched to integrations without adding it. This caused identity tracking to break for first-party re-hosted integrations like Permutive's secure-signal endpoint. Centralizing the header logic in handle_proxy ensures all current and future integrations automatically include the synthetic ID, rather than requiring each integration to implement it manually. Fixes #205 --- crates/common/src/integrations/registry.rs | 246 +++++++++++++++++++- crates/common/src/integrations/testlight.rs | 2 - trusted-server.toml | 5 - 3 files changed, 244 insertions(+), 9 deletions(-) diff --git a/crates/common/src/integrations/registry.rs b/crates/common/src/integrations/registry.rs index 7fe9e77..d2449d6 100644 --- a/crates/common/src/integrations/registry.rs +++ b/crates/common/src/integrations/registry.rs @@ -4,12 +4,15 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use error_stack::Report; -use fastly::http::Method; +use fastly::http::{header, Method}; use fastly::{Request, Response}; use matchit::Router; +use crate::constants::{COOKIE_SYNTHETIC_ID, HEADER_X_SYNTHETIC_ID}; +use crate::cookies::create_synthetic_cookie; use crate::error::TrustedServerError; use crate::settings::Settings; +use crate::synthetic::get_or_generate_synthetic_id; /// Action returned by attribute rewriters to describe how the runtime should mutate the element. #[derive(Debug, Clone, PartialEq, Eq)] @@ -602,6 +605,9 @@ impl IntegrationRegistry { } /// Dispatch a proxy request when an integration handles the path. + /// + /// This method automatically sets the `x-synthetic-id` header and + /// `synthetic_id` cookie on successful responses. #[must_use] pub async fn handle_proxy( &self, @@ -611,7 +617,35 @@ impl IntegrationRegistry { req: Request, ) -> Option>> { if let Some((proxy, _)) = self.find_route(method, path) { - Some(proxy.handle(settings, req).await) + // Generate synthetic ID before consuming request + let synthetic_id_result = get_or_generate_synthetic_id(settings, &req); + let has_synthetic_cookie = req + .get_header(header::COOKIE) + .and_then(|h| h.to_str().ok()) + .map(|cookies| { + cookies.split(';').any(|cookie| { + cookie + .trim_start() + .starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID)) + }) + }) + .unwrap_or(false); + + let mut result = proxy.handle(settings, req).await; + + // Set synthetic ID header on successful responses + if let Ok(ref mut response) = result { + if let Ok(ref synthetic_id) = synthetic_id_result { + response.set_header(HEADER_X_SYNTHETIC_ID, synthetic_id.as_str()); + if !has_synthetic_cookie { + response.set_header( + header::SET_COOKIE, + create_synthetic_cookie(settings, synthetic_id.as_str()), + ); + } + } + } + Some(result) } else { None } @@ -1042,4 +1076,212 @@ mod tests { assert!(!registry.has_route(&Method::GET, "/integrations/test/users")); assert!(!registry.has_route(&Method::POST, "/integrations/test/users")); } + + // Tests for synthetic ID header on proxy responses + + #[test] + fn cookie_detection_finds_synthetic_id() { + let cookies = "other=value; synthetic_id=abc123; more=stuff"; + let has_cookie = cookies.split(';').any(|cookie| { + cookie + .trim_start() + .starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID)) + }); + assert!(has_cookie, "Should detect synthetic_id cookie"); + } + + #[test] + fn cookie_detection_handles_missing_cookie() { + let cookies = "other=value; session=xyz"; + let has_cookie = cookies.split(';').any(|cookie| { + cookie + .trim_start() + .starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID)) + }); + assert!(!has_cookie, "Should not find synthetic_id when missing"); + } + + #[test] + fn cookie_detection_handles_empty_cookies() { + let cookies = ""; + let has_cookie = cookies.split(';').any(|cookie| { + cookie + .trim_start() + .starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID)) + }); + assert!(!has_cookie, "Should handle empty cookie string"); + } + + // Async tests for handle_proxy synthetic ID functionality + + use crate::test_support::tests::create_test_settings; + + /// Mock proxy that returns a simple 200 OK response + struct SyntheticIdTestProxy; + + #[async_trait(?Send)] + impl IntegrationProxy for SyntheticIdTestProxy { + fn integration_name(&self) -> &'static str { + "synthetic_id_test" + } + + fn routes(&self) -> Vec { + vec![ + IntegrationEndpoint { + method: Method::GET, + path: "/integrations/test/synthetic".to_string(), + }, + IntegrationEndpoint { + method: Method::POST, + path: "/integrations/test/synthetic".to_string(), + }, + ] + } + + async fn handle( + &self, + _settings: &Settings, + _req: Request, + ) -> Result> { + // Return a simple response without the synthetic ID header. + // The registry's handle_proxy should add it. + Ok(Response::from_status(fastly::http::StatusCode::OK).with_body("test response")) + } + } + + #[test] + fn handle_proxy_sets_synthetic_id_header_on_response() { + let settings = create_test_settings(); + let routes = vec![( + Method::GET, + "/integrations/test/synthetic", + ( + Arc::new(SyntheticIdTestProxy) as Arc, + "synthetic_id_test", + ), + )]; + let registry = IntegrationRegistry::from_routes(routes); + + // Create a request without a synthetic ID cookie + let req = Request::get("https://test-publisher.com/integrations/test/synthetic"); + + // Call handle_proxy (uses futures executor in test environment) + let result = futures::executor::block_on(registry.handle_proxy( + &Method::GET, + "/integrations/test/synthetic", + &settings, + req, + )); + + // Should have matched and returned a response + assert!(result.is_some(), "Should find route and handle request"); + let response = result.unwrap(); + assert!(response.is_ok(), "Handler should succeed"); + + let response = response.unwrap(); + + // Verify x-synthetic-id header is present + assert!( + response.get_header(HEADER_X_SYNTHETIC_ID).is_some(), + "Response should have x-synthetic-id header" + ); + + // Verify Set-Cookie header is present (since no cookie was in request) + let set_cookie = response.get_header(header::SET_COOKIE); + assert!( + set_cookie.is_some(), + "Response should have Set-Cookie header for synthetic_id" + ); + + let cookie_value = set_cookie.unwrap().to_str().unwrap(); + assert!( + cookie_value.contains(COOKIE_SYNTHETIC_ID), + "Set-Cookie should contain synthetic_id cookie, got: {}", + cookie_value + ); + } + + #[test] + fn handle_proxy_skips_cookie_when_already_present() { + let settings = create_test_settings(); + let routes = vec![( + Method::GET, + "/integrations/test/synthetic", + ( + Arc::new(SyntheticIdTestProxy) as Arc, + "synthetic_id_test", + ), + )]; + let registry = IntegrationRegistry::from_routes(routes); + + // Create a request WITH an existing synthetic_id cookie + let mut req = Request::get("https://test-publisher.com/integrations/test/synthetic"); + req.set_header(header::COOKIE, "synthetic_id=existing_id_12345"); + + let result = futures::executor::block_on(registry.handle_proxy( + &Method::GET, + "/integrations/test/synthetic", + &settings, + req, + )); + + assert!(result.is_some(), "Should find route"); + let response = result.unwrap(); + assert!(response.is_ok(), "Handler should succeed"); + + let response = response.unwrap(); + + // Should still have x-synthetic-id header + assert!( + response.get_header(HEADER_X_SYNTHETIC_ID).is_some(), + "Response should still have x-synthetic-id header" + ); + + // But should NOT set the cookie again (it's already present) + let set_cookie = response.get_header(header::SET_COOKIE); + + // Either no Set-Cookie, or if present, not for synthetic_id + if let Some(cookie) = set_cookie { + let cookie_str = cookie.to_str().unwrap_or(""); + assert!( + !cookie_str.contains(COOKIE_SYNTHETIC_ID), + "Should not set duplicate synthetic_id cookie, got: {}", + cookie_str + ); + } + } + + #[test] + fn handle_proxy_works_with_post_method() { + let settings = create_test_settings(); + let routes = vec![( + Method::POST, + "/integrations/test/synthetic", + ( + Arc::new(SyntheticIdTestProxy) as Arc, + "synthetic_id_test", + ), + )]; + let registry = IntegrationRegistry::from_routes(routes); + + let req = Request::post("https://test-publisher.com/integrations/test/synthetic") + .with_body("test body"); + + let result = futures::executor::block_on(registry.handle_proxy( + &Method::POST, + "/integrations/test/synthetic", + &settings, + req, + )); + + assert!(result.is_some(), "Should find POST route"); + let response = result.unwrap(); + assert!(response.is_ok(), "Handler should succeed"); + + let response = response.unwrap(); + assert!( + response.get_header(HEADER_X_SYNTHETIC_ID).is_some(), + "POST response should have x-synthetic-id header" + ); + } } diff --git a/crates/common/src/integrations/testlight.rs b/crates/common/src/integrations/testlight.rs index b6883ac..8dd3ec7 100644 --- a/crates/common/src/integrations/testlight.rs +++ b/crates/common/src/integrations/testlight.rs @@ -8,7 +8,6 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use validator::Validate; -use crate::constants::HEADER_X_SYNTHETIC_ID; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, @@ -175,7 +174,6 @@ impl IntegrationProxy for TestlightIntegration { } } - response.set_header(HEADER_X_SYNTHETIC_ID, &synthetic_id); Ok(response) } } diff --git a/trusted-server.toml b/trusted-server.toml index 2e22c06..daa0214 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -21,11 +21,6 @@ secret_key = "trusted-server" # - "random_uuid" template = "{{ client_ip }}:{{ user_agent }}:{{ accept_language }}:{{ accept_encoding }}" -# Custom headers to be included in every response -# Allows publishers to include tags such as X-Robots-Tag: noindex -# [response_headers] -# X-Custom-Header = "custom header value" - # Request Signing Configuration # Enable signing of OpenRTB requests and other API calls [request_signing] From a0d22b2672df359f409665fcb9b6a6bbc529fa34 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 7 Feb 2026 08:32:05 +0530 Subject: [PATCH 2/2] Use cookie.rs utilities for synthetic ID detection Replaces inline cookie parsing with handle_request_cookies() and parse_cookies_to_jar() from cookies.rs for consistency. --- crates/common/src/integrations/registry.rs | 62 +++++++++------------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/crates/common/src/integrations/registry.rs b/crates/common/src/integrations/registry.rs index d2449d6..e3c611b 100644 --- a/crates/common/src/integrations/registry.rs +++ b/crates/common/src/integrations/registry.rs @@ -9,7 +9,7 @@ use fastly::{Request, Response}; use matchit::Router; use crate::constants::{COOKIE_SYNTHETIC_ID, HEADER_X_SYNTHETIC_ID}; -use crate::cookies::create_synthetic_cookie; +use crate::cookies::{create_synthetic_cookie, handle_request_cookies}; use crate::error::TrustedServerError; use crate::settings::Settings; use crate::synthetic::get_or_generate_synthetic_id; @@ -619,16 +619,10 @@ impl IntegrationRegistry { if let Some((proxy, _)) = self.find_route(method, path) { // Generate synthetic ID before consuming request let synthetic_id_result = get_or_generate_synthetic_id(settings, &req); - let has_synthetic_cookie = req - .get_header(header::COOKIE) - .and_then(|h| h.to_str().ok()) - .map(|cookies| { - cookies.split(';').any(|cookie| { - cookie - .trim_start() - .starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID)) - }) - }) + let has_synthetic_cookie = handle_request_cookies(&req) + .ok() + .flatten() + .and_then(|jar| jar.get(COOKIE_SYNTHETIC_ID).map(|_| true)) .unwrap_or(false); let mut result = proxy.handle(settings, req).await; @@ -1079,43 +1073,39 @@ mod tests { // Tests for synthetic ID header on proxy responses + use crate::cookies::parse_cookies_to_jar; + use crate::test_support::tests::create_test_settings; + #[test] - fn cookie_detection_finds_synthetic_id() { + fn cookie_jar_finds_synthetic_id() { let cookies = "other=value; synthetic_id=abc123; more=stuff"; - let has_cookie = cookies.split(';').any(|cookie| { - cookie - .trim_start() - .starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID)) - }); - assert!(has_cookie, "Should detect synthetic_id cookie"); + let jar = parse_cookies_to_jar(cookies); + assert!( + jar.get(COOKIE_SYNTHETIC_ID).is_some(), + "Should detect synthetic_id cookie" + ); } #[test] - fn cookie_detection_handles_missing_cookie() { + fn cookie_jar_handles_missing_cookie() { let cookies = "other=value; session=xyz"; - let has_cookie = cookies.split(';').any(|cookie| { - cookie - .trim_start() - .starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID)) - }); - assert!(!has_cookie, "Should not find synthetic_id when missing"); + let jar = parse_cookies_to_jar(cookies); + assert!( + jar.get(COOKIE_SYNTHETIC_ID).is_none(), + "Should not find synthetic_id when missing" + ); } #[test] - fn cookie_detection_handles_empty_cookies() { + fn cookie_jar_handles_empty_cookies() { let cookies = ""; - let has_cookie = cookies.split(';').any(|cookie| { - cookie - .trim_start() - .starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID)) - }); - assert!(!has_cookie, "Should handle empty cookie string"); + let jar = parse_cookies_to_jar(cookies); + assert!( + jar.get(COOKIE_SYNTHETIC_ID).is_none(), + "Should handle empty cookie string" + ); } - // Async tests for handle_proxy synthetic ID functionality - - use crate::test_support::tests::create_test_settings; - /// Mock proxy that returns a simple 200 OK response struct SyntheticIdTestProxy;