From f972db0c4bef15cd826a0a4e5178277f232edd89 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 19 Mar 2026 14:59:57 -0500 Subject: [PATCH 1/2] Use local request info for Prebid response URL rewriting Store RequestInfo from the original client request on the provider during request_bids and use it in parse_response for URL rewriting. Previously, host and scheme were read back from the upstream Prebid Server response body, allowing a compromised or misconfigured bidder to inject arbitrary values into ad markup URL rewrites. The request_host and request_scheme fields are still sent to Prebid Server in the TrustedServerExt for the signing protocol, but the response-side values are no longer trusted for rewriting. Closes #417 --- .../src/integrations/prebid.rs | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 901071e4..93ec139e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use async_trait::async_trait; @@ -545,13 +545,21 @@ fn append_query_params(url: &str, params: &str) -> String { /// Prebid Server auction provider. pub struct PrebidAuctionProvider { config: PrebidIntegrationConfig, + /// Cached request info from the original client request, captured during + /// [`request_bids`] and used in [`parse_response`] for URL rewriting. + /// This avoids trusting the upstream Prebid Server response to faithfully + /// echo back the host and scheme values we sent. + request_info: OnceLock, } impl PrebidAuctionProvider { /// Create a new Prebid auction provider. #[must_use] pub fn new(config: PrebidIntegrationConfig) -> Self { - Self { config } + Self { + config, + request_info: OnceLock::new(), + } } /// Convert auction request to `OpenRTB` format with all enrichments. @@ -1082,6 +1090,12 @@ impl AuctionProvider for PrebidAuctionProvider { ) -> Result> { log::info!("Prebid: requesting bids for {} slots", request.slots.len()); + // Capture request info from the original client request for use in + // parse_response. This ensures URL rewriting uses locally trusted + // values rather than values echoed back by the upstream Prebid Server. + let captured_info = RequestInfo::from_request(context.request); + let _ = self.request_info.set(captured_info); + // Create signer and compute signature if request signing is enabled let signer_with_signature = if let Some(request_signing_config) = &context.settings.request_signing @@ -1205,25 +1219,13 @@ impl AuctionProvider for PrebidAuctionProvider { } } - let request_host = response_json - .get("ext") - .and_then(|ext| ext.get("trusted_server")) - .and_then(|trusted_server| trusted_server.get("request_host")) - .and_then(|value| value.as_str()) - .unwrap_or("") - .to_string(); - let request_scheme = response_json - .get("ext") - .and_then(|ext| ext.get("trusted_server")) - .and_then(|trusted_server| trusted_server.get("request_scheme")) - .and_then(|value| value.as_str()) - .unwrap_or("https") - .to_string(); - - if request_host.is_empty() { - log::warn!("Prebid response missing request host; skipping URL rewrites"); + // Use locally captured request info for URL rewriting instead of + // trusting the upstream Prebid Server response body. A compromised or + // misconfigured bidder could inject arbitrary host/scheme values. + if let Some(info) = self.request_info.get() { + transform_prebid_response(&mut response_json, &info.host, &info.scheme)?; } else { - transform_prebid_response(&mut response_json, &request_host, &request_scheme)?; + log::warn!("Prebid request info not captured; skipping URL rewrites"); } let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); From 9bce2eafde68d9e79c391d783676921803a72445 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 19 Mar 2026 15:51:15 -0500 Subject: [PATCH 2/2] Remove dead ad-proxy URL rewriting from Prebid parse_response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transform_prebid_response, rewrite_ad_markup, and make_first_party_proxy_url functions generated /ad-proxy/ URLs whose route handler was removed in 25084ba (NextJS with Prebid Integration). The downstream creative::rewrite_creative_html already rewrites all creative URLs to /first-party/proxy, making the Prebid-level rewriting both dead and harmful (it produced double-rewritten URLs pointing to a non-existent endpoint). Removing this dead code also eliminates the security issue where request_host and request_scheme were read from the upstream Prebid Server response body (#417) — there is simply no response-side URL rewriting left to trust or distrust. Closes #417 --- .../src/integrations/prebid.rs | 170 +----------------- 1 file changed, 4 insertions(+), 166 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 93ec139e..266a31b2 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,14 +1,13 @@ use std::collections::HashMap; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use error_stack::{Report, ResultExt}; use fastly::http::{header, Method, StatusCode, Url}; use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value as Json}; +use serde_json::Value as Json; use validator::Validate; use crate::auction::provider::AuctionProvider; @@ -417,88 +416,6 @@ fn expand_trusted_server_bidders( }) .collect() } -fn transform_prebid_response( - response: &mut Json, - request_host: &str, - request_scheme: &str, -) -> Result<(), Report> { - if let Some(seatbids) = response["seatbid"].as_array_mut() { - for seatbid in seatbids { - if let Some(bids) = seatbid["bid"].as_array_mut() { - for bid in bids { - if let Some(adm) = bid["adm"].as_str() { - bid["adm"] = json!(rewrite_ad_markup(adm, request_host, request_scheme)); - } - - if let Some(nurl) = bid["nurl"].as_str() { - bid["nurl"] = json!(make_first_party_proxy_url( - nurl, - request_host, - request_scheme, - "track" - )); - } - - if let Some(burl) = bid["burl"].as_str() { - bid["burl"] = json!(make_first_party_proxy_url( - burl, - request_host, - request_scheme, - "track" - )); - } - } - } - } - } - - Ok(()) -} - -fn rewrite_ad_markup(markup: &str, request_host: &str, request_scheme: &str) -> String { - let mut content = markup.to_string(); - let cdn_patterns = [ - ("https://cdn.adsrvr.org", "adsrvr"), - ("https://ib.adnxs.com", "adnxs"), - ("https://rtb.openx.net", "openx"), - ("https://as.casalemedia.com", "casale"), - ("https://eus.rubiconproject.com", "rubicon"), - ]; - - for (cdn_url, cdn_name) in cdn_patterns { - if content.contains(cdn_url) { - let proxy_base = format!( - "{}://{}/ad-proxy/{}", - request_scheme, request_host, cdn_name - ); - content = content.replace(cdn_url, &proxy_base); - } - } - - content = content.replace( - "//cdn.adsrvr.org", - &format!("//{}/ad-proxy/adsrvr", request_host), - ); - content = content.replace( - "//ib.adnxs.com", - &format!("//{}/ad-proxy/adnxs", request_host), - ); - content -} - -fn make_first_party_proxy_url( - third_party_url: &str, - request_host: &str, - request_scheme: &str, - proxy_type: &str, -) -> String { - let encoded = BASE64.encode(third_party_url.as_bytes()); - format!( - "{}://{}/ad-proxy/{}/{}", - request_scheme, request_host, proxy_type, encoded - ) -} - /// Copies browser headers to the outgoing Prebid Server request. /// /// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are @@ -545,21 +462,13 @@ fn append_query_params(url: &str, params: &str) -> String { /// Prebid Server auction provider. pub struct PrebidAuctionProvider { config: PrebidIntegrationConfig, - /// Cached request info from the original client request, captured during - /// [`request_bids`] and used in [`parse_response`] for URL rewriting. - /// This avoids trusting the upstream Prebid Server response to faithfully - /// echo back the host and scheme values we sent. - request_info: OnceLock, } impl PrebidAuctionProvider { /// Create a new Prebid auction provider. #[must_use] pub fn new(config: PrebidIntegrationConfig) -> Self { - Self { - config, - request_info: OnceLock::new(), - } + Self { config } } /// Convert auction request to `OpenRTB` format with all enrichments. @@ -1090,12 +999,6 @@ impl AuctionProvider for PrebidAuctionProvider { ) -> Result> { log::info!("Prebid: requesting bids for {} slots", request.slots.len()); - // Capture request info from the original client request for use in - // parse_response. This ensures URL rewriting uses locally trusted - // values rather than values echoed back by the upstream Prebid Server. - let captured_info = RequestInfo::from_request(context.request); - let _ = self.request_info.set(captured_info); - // Create signer and compute signature if request signing is enabled let signer_with_signature = if let Some(request_signing_config) = &context.settings.request_signing @@ -1203,7 +1106,7 @@ impl AuctionProvider for PrebidAuctionProvider { return Ok(AuctionResponse::error("prebid", response_time_ms)); } - let mut response_json: Json = + let response_json: Json = serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { message: "Failed to parse Prebid response".to_string(), })?; @@ -1219,15 +1122,6 @@ impl AuctionProvider for PrebidAuctionProvider { } } - // Use locally captured request info for URL rewriting instead of - // trusting the upstream Prebid Server response body. A compromised or - // misconfigured bidder could inject arbitrary host/scheme values. - if let Some(info) = self.request_info.get() { - transform_prebid_response(&mut response_json, &info.host, &info.scheme)?; - } else { - log::warn!("Prebid request info not captured; skipping URL rewrites"); - } - let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); self.enrich_response_metadata(&response_json, &mut auction_response); @@ -1572,62 +1466,6 @@ template = "{{client_ip}}:{{user_agent}}" ); } - #[test] - fn transform_prebid_response_rewrites_creatives_and_tracking() { - let mut response = json!({ - "seatbid": [{ - "bid": [{ - "adm": r#""#, - "nurl": "https://notify.example/win", - "burl": "https://notify.example/bill" - }] - }] - }); - - transform_prebid_response(&mut response, "pub.example", "https") - .expect("should rewrite response"); - - let rewritten_adm = response["seatbid"][0]["bid"][0]["adm"] - .as_str() - .expect("adm should be string"); - assert!( - rewritten_adm.contains("/ad-proxy/adsrvr"), - "creative markup should proxy CDN urls" - ); - - for url_field in ["nurl", "burl"] { - let value = response["seatbid"][0]["bid"][0][url_field] - .as_str() - .expect("should get tracking URL"); - assert!( - value.contains("/ad-proxy/track/"), - "tracking URLs should be proxied" - ); - } - } - - #[test] - fn make_first_party_proxy_url_base64_encodes_target() { - let url = "https://cdn.example/path?x=1"; - let rewritten = make_first_party_proxy_url(url, "pub.example", "https", "track"); - assert!( - rewritten.starts_with("https://pub.example/ad-proxy/track/"), - "proxy prefix should be applied" - ); - - let encoded = rewritten - .split("/ad-proxy/track/") - .nth(1) - .expect("should have encoded payload after proxy prefix"); - let decoded = BASE64 - .decode(encoded.as_bytes()) - .expect("should decode base64 proxy payload"); - assert_eq!( - String::from_utf8(decoded).expect("should be valid UTF-8"), - url - ); - } - #[test] fn matches_script_url_matches_common_variants() { let integration = PrebidIntegration::new(base_config());