From 563c281e03f54504013840bc54b64b56c9d8d62f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:13:32 -0800 Subject: [PATCH 1/2] Improve forwarded header parsing for host and scheme --- crates/common/src/http_util.rs | 265 +++++++++++++++++++++++ crates/common/src/integrations/prebid.rs | 31 +-- crates/common/src/publisher.rs | 155 +------------ crates/common/src/synthetic.rs | 11 +- 4 files changed, 291 insertions(+), 171 deletions(-) diff --git a/crates/common/src/http_util.rs b/crates/common/src/http_util.rs index 132d6bd..c830aab 100644 --- a/crates/common/src/http_util.rs +++ b/crates/common/src/http_util.rs @@ -6,6 +6,157 @@ use sha2::{Digest, Sha256}; use crate::settings::Settings; +/// Extracted request information for host rewriting. +/// +/// This struct captures the effective host and scheme from an incoming request, +/// accounting for proxy headers like `X-Forwarded-Host` and `X-Forwarded-Proto`. +#[derive(Debug, Clone)] +pub struct RequestInfo { + /// The effective host for URL rewriting (from Forwarded, X-Forwarded-Host, or Host header) + pub host: String, + /// The effective scheme (from TLS detection, Forwarded, X-Forwarded-Proto, or default) + pub scheme: String, +} + +impl RequestInfo { + /// Extract request info from a Fastly request. + /// + /// Host priority: + /// 1. `Forwarded` header (RFC 7239, `host=...`) + /// 2. `X-Forwarded-Host` header (for chained proxy setups) + /// 3. `Host` header + /// + /// Scheme priority: + /// 1. Fastly SDK TLS detection (most reliable) + /// 2. `Forwarded` header (RFC 7239, `proto=https`) + /// 3. `X-Forwarded-Proto` header + /// 4. `Fastly-SSL` header + /// 5. Default to `http` + pub fn from_request(req: &Request) -> Self { + let host = extract_request_host(req); + let scheme = detect_request_scheme(req); + + Self { host, scheme } + } +} + +fn extract_request_host(req: &Request) -> String { + req.get_header("forwarded") + .and_then(|h| h.to_str().ok()) + .and_then(|value| parse_forwarded_param(value, "host")) + .or_else(|| { + req.get_header("x-forwarded-host") + .and_then(|h| h.to_str().ok()) + .and_then(parse_list_header_value) + }) + .or_else(|| req.get_header(header::HOST).and_then(|h| h.to_str().ok())) + .unwrap_or_default() + .to_string() +} + +fn parse_forwarded_param<'a>(forwarded: &'a str, param: &str) -> Option<&'a str> { + for entry in forwarded.split(',') { + for part in entry.split(';') { + let mut iter = part.splitn(2, '='); + let key = iter.next().unwrap_or("").trim(); + let value = iter.next().unwrap_or("").trim(); + if key.is_empty() || value.is_empty() { + continue; + } + if key.eq_ignore_ascii_case(param) { + let value = strip_quotes(value); + if !value.is_empty() { + return Some(value); + } + } + } + } + None +} + +fn parse_list_header_value(value: &str) -> Option<&str> { + value + .split(',') + .map(|part| part.trim()) + .find(|part| !part.is_empty()) + .map(strip_quotes) + .filter(|part| !part.is_empty()) +} + +fn strip_quotes(value: &str) -> &str { + let trimmed = value.trim(); + if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + } +} + +fn normalize_scheme(value: &str) -> Option { + let scheme = value.trim().to_ascii_lowercase(); + if scheme == "https" || scheme == "http" { + Some(scheme) + } else { + None + } +} + +/// Detects the request scheme (HTTP or HTTPS) using Fastly SDK methods and headers. +/// +/// Tries multiple methods in order of reliability: +/// 1. Fastly SDK TLS detection methods (most reliable) +/// 2. Forwarded header (RFC 7239) +/// 3. X-Forwarded-Proto header +/// 4. Fastly-SSL header (least reliable, can be spoofed) +/// 5. Default to HTTP +fn detect_request_scheme(req: &Request) -> String { + // 1. First try Fastly SDK's built-in TLS detection methods + if let Some(tls_protocol) = req.get_tls_protocol() { + log::debug!("TLS protocol detected: {}", tls_protocol); + return "https".to_string(); + } + + // Also check TLS cipher - if present, connection is HTTPS + if req.get_tls_cipher_openssl_name().is_some() { + log::debug!("TLS cipher detected, using HTTPS"); + return "https".to_string(); + } + + // 2. Try the Forwarded header (RFC 7239) + if let Some(forwarded) = req.get_header("forwarded") { + if let Ok(forwarded_str) = forwarded.to_str() { + if let Some(proto) = parse_forwarded_param(forwarded_str, "proto") { + if let Some(scheme) = normalize_scheme(proto) { + return scheme; + } + } + } + } + + // 3. Try X-Forwarded-Proto header + if let Some(proto) = req.get_header("x-forwarded-proto") { + if let Ok(proto_str) = proto.to_str() { + if let Some(value) = parse_list_header_value(proto_str) { + if let Some(scheme) = normalize_scheme(value) { + return scheme; + } + } + } + } + + // 4. Check Fastly-SSL header (can be spoofed by clients, use as last resort) + if let Some(ssl) = req.get_header("fastly-ssl") { + if let Ok(ssl_str) = ssl.to_str() { + if ssl_str == "1" || ssl_str.to_lowercase() == "true" { + return "https".to_string(); + } + } + } + + // Default to HTTP + "http".to_string() +} + /// Build a static text response with strong ETag and standard caching headers. /// Handles If-None-Match to return 304 when appropriate. pub fn serve_static_with_etag(body: &str, req: &Request, content_type: &str) -> Response { @@ -166,4 +317,118 @@ mod tests { &t1 )); } + + // RequestInfo tests + + #[test] + fn test_request_info_from_host_header() { + let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + req.set_header("host", "test.example.com"); + + let info = RequestInfo::from_request(&req); + assert_eq!( + info.host, "test.example.com", + "Host should use Host header when forwarded headers are missing" + ); + // No TLS or forwarded headers, defaults to http. + assert_eq!( + info.scheme, "http", + "Scheme should default to http without TLS or forwarded headers" + ); + } + + #[test] + fn test_request_info_x_forwarded_host_precedence() { + let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + req.set_header("host", "internal-proxy.local"); + req.set_header("x-forwarded-host", "public.example.com, proxy.local"); + + let info = RequestInfo::from_request(&req); + assert_eq!( + info.host, "public.example.com", + "Host should prefer X-Forwarded-Host over Host" + ); + } + + #[test] + fn test_request_info_scheme_from_x_forwarded_proto() { + let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + req.set_header("host", "test.example.com"); + req.set_header("x-forwarded-proto", "https, http"); + + let info = RequestInfo::from_request(&req); + assert_eq!( + info.scheme, "https", + "Scheme should prefer the first X-Forwarded-Proto value" + ); + + // Test HTTP + let mut req = Request::new(fastly::http::Method::GET, "http://test.example.com/page"); + req.set_header("host", "test.example.com"); + req.set_header("x-forwarded-proto", "http"); + + let info = RequestInfo::from_request(&req); + assert_eq!( + info.scheme, "http", + "Scheme should use the X-Forwarded-Proto value when present" + ); + } + + #[test] + fn request_info_forwarded_header_precedence() { + // Forwarded header takes precedence over X-Forwarded-Proto + let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + req.set_header( + "forwarded", + "for=192.0.2.60;proto=\"HTTPS\";host=\"public.example.com:443\"", + ); + req.set_header("host", "internal-proxy.local"); + req.set_header("x-forwarded-host", "proxy.local"); + req.set_header("x-forwarded-proto", "http"); + + let info = RequestInfo::from_request(&req); + assert_eq!( + info.host, "public.example.com:443", + "Host should prefer Forwarded host over X-Forwarded-Host" + ); + assert_eq!( + info.scheme, "https", + "Scheme should prefer Forwarded proto over X-Forwarded-Proto" + ); + } + + #[test] + fn test_request_info_scheme_from_fastly_ssl() { + let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + req.set_header("fastly-ssl", "1"); + + let info = RequestInfo::from_request(&req); + assert_eq!( + info.scheme, "https", + "Scheme should fall back to Fastly-SSL when other signals are missing" + ); + } + + #[test] + fn test_request_info_chained_proxy_scenario() { + // Simulate: Client (HTTPS) -> Proxy A -> Trusted Server (HTTP internally) + // Proxy A sets X-Forwarded-Host and X-Forwarded-Proto + let mut req = Request::new( + fastly::http::Method::GET, + "http://trusted-server.internal/page", + ); + req.set_header("host", "trusted-server.internal"); + req.set_header("x-forwarded-host", "public.example.com"); + req.set_header("x-forwarded-proto", "https"); + + let info = RequestInfo::from_request(&req); + assert_eq!( + info.host, "public.example.com", + "Host should use X-Forwarded-Host in chained proxy scenarios" + ); + assert_eq!( + info.scheme, "https", + "Scheme should use X-Forwarded-Proto in chained proxy scenarios" + ); + } } diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 701a325..822990c 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -16,6 +16,7 @@ use crate::constants::{HEADER_SYNTHETIC_FRESH, HEADER_SYNTHETIC_TRUSTED_SERVER}; use crate::creative; use crate::error::TrustedServerError; use crate::geo::GeoInfo; +use crate::http_util::RequestInfo; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, @@ -562,9 +563,12 @@ async fn handle_prebid_auction( let response_body = pbs_response.take_body_bytes(); match serde_json::from_slice::(&response_body) { Ok(mut response_json) => { - let request_host = get_request_host(&req); - let request_scheme = get_request_scheme(&req); - transform_prebid_response(&mut response_json, &request_host, &request_scheme)?; + let request_info = RequestInfo::from_request(&req); + transform_prebid_response( + &mut response_json, + &request_info.host, + &request_info.scheme, + )?; let transformed_body = serde_json::to_vec(&response_json).change_context( TrustedServerError::Prebid { @@ -755,26 +759,7 @@ fn copy_request_headers(from: &Request, to: &mut Request) { } } -fn get_request_host(req: &Request) -> String { - req.get_header(header::HOST) - .and_then(|h| h.to_str().ok()) - .unwrap_or("") - .to_string() -} - -fn get_request_scheme(req: &Request) -> String { - if req.get_tls_protocol().is_some() || req.get_tls_cipher_openssl_name().is_some() { - return "https".to_string(); - } - - if let Some(proto) = req.get_header("X-Forwarded-Proto") { - if let Ok(proto_str) = proto.to_str() { - return proto_str.to_lowercase(); - } - } - - "https".to_string() -} +// Request host/scheme extraction is now centralized in http_util::RequestInfo #[cfg(test)] mod tests { diff --git a/crates/common/src/publisher.rs b/crates/common/src/publisher.rs index 6041536..55f73b5 100644 --- a/crates/common/src/publisher.rs +++ b/crates/common/src/publisher.rs @@ -3,7 +3,7 @@ use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; use crate::backend::ensure_backend_from_url; -use crate::http_util::serve_static_with_etag; +use crate::http_util::{serve_static_with_etag, RequestInfo}; use crate::constants::{HEADER_SYNTHETIC_TRUSTED_SERVER, HEADER_X_COMPRESS_HINT}; use crate::cookies::create_synthetic_cookie; @@ -15,65 +15,6 @@ use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, S use crate::streaming_replacer::create_url_replacer; use crate::synthetic::get_or_generate_synthetic_id; -/// Detects the request scheme (HTTP or HTTPS) using Fastly SDK methods and headers. -/// -/// Tries multiple methods in order of reliability: -/// 1. Fastly SDK TLS detection methods (most reliable) -/// 2. Forwarded header (RFC 7239) -/// 3. X-Forwarded-Proto header -/// 4. Fastly-SSL header (least reliable, can be spoofed) -/// 5. Default to HTTP -fn detect_request_scheme(req: &Request) -> String { - // 1. First try Fastly SDK's built-in TLS detection methods - // These are the most reliable as they check the actual connection - if let Some(tls_protocol) = req.get_tls_protocol() { - // If we have a TLS protocol, the connection is definitely HTTPS - log::debug!("TLS protocol detected: {}", tls_protocol); - return "https".to_string(); - } - - // Also check TLS cipher - if present, connection is HTTPS - if req.get_tls_cipher_openssl_name().is_some() { - log::debug!("TLS cipher detected, using HTTPS"); - return "https".to_string(); - } - - // 2. Try the Forwarded header (RFC 7239) - if let Some(forwarded) = req.get_header("forwarded") { - if let Ok(forwarded_str) = forwarded.to_str() { - // Parse the Forwarded header - // Format: Forwarded: for=192.0.2.60;proto=https;by=203.0.113.43 - if forwarded_str.contains("proto=https") { - return "https".to_string(); - } else if forwarded_str.contains("proto=http") { - return "http".to_string(); - } - } - } - - // 3. Try X-Forwarded-Proto header - if let Some(proto) = req.get_header("x-forwarded-proto") { - if let Ok(proto_str) = proto.to_str() { - let proto_lower = proto_str.to_lowercase(); - if proto_lower == "https" || proto_lower == "http" { - return proto_lower; - } - } - } - - // 4. Check Fastly-SSL header (can be spoofed by clients, use as last resort) - if let Some(ssl) = req.get_header("fastly-ssl") { - if let Ok(ssl_str) = ssl.to_str() { - if ssl_str == "1" || ssl_str.to_lowercase() == "true" { - return "https".to_string(); - } - } - } - - // Default to HTTP (changed from HTTPS based on your settings file) - "http".to_string() -} - /// Unified tsjs static serving: `/static/tsjs=` /// Accepts: `tsjs-core(.min).js`, `tsjs-ext(.min).js`, `tsjs-creative(.min).js` pub fn handle_tsjs_dynamic( @@ -238,29 +179,20 @@ pub fn handle_publisher_request( // Prebid.js requests are not intercepted here anymore. The HTML processor rewrites // any Prebid script references to `/static/tsjs-ext.min.js` when auto-configure is enabled. - // Extract the request host from the incoming request - let request_host = req - .get_header(header::HOST) - .map(|h| h.to_str().unwrap_or_default()) - .unwrap_or_default() - .to_string(); + // Extract request host and scheme from headers (supports X-Forwarded-Host/Proto for chained proxies) + let request_info = RequestInfo::from_request(&req); + let request_host = &request_info.host; + let request_scheme = &request_info.scheme; - // Detect the request scheme using multiple methods - let request_scheme = detect_request_scheme(&req); - - // Log detection details for debugging log::debug!( - "Scheme detection - TLS Protocol: {:?}, TLS Cipher: {:?}, Forwarded: {:?}, X-Forwarded-Proto: {:?}, Fastly-SSL: {:?}, Result: {}", - req.get_tls_protocol(), - req.get_tls_cipher_openssl_name(), - req.get_header("forwarded"), + "Request info: host={}, scheme={} (X-Forwarded-Host: {:?}, Host: {:?}, X-Forwarded-Proto: {:?})", + request_host, + request_scheme, + req.get_header("x-forwarded-host"), + req.get_header(header::HOST), req.get_header("x-forwarded-proto"), - req.get_header("fastly-ssl"), - request_scheme ); - log::debug!("Request host: {}, scheme: {}", request_host, request_scheme); - // Generate synthetic identifiers before the request body is consumed. let synthetic_id = get_or_generate_synthetic_id(settings, &req)?; let has_synthetic_cookie = req @@ -387,73 +319,6 @@ mod tests { use crate::test_support::tests::create_test_settings; use fastly::http::Method; - #[test] - fn test_detect_request_scheme() { - // Note: In tests, we can't mock the TLS methods on Request, so we test header fallbacks - - // Test Forwarded header with HTTPS - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header("forwarded", "for=192.0.2.60;proto=https;by=203.0.113.43"); - assert_eq!(detect_request_scheme(&req), "https"); - - // Test Forwarded header with HTTP - let mut req = Request::new(Method::GET, "http://test.example.com/page"); - req.set_header("forwarded", "for=192.0.2.60;proto=http;by=203.0.113.43"); - assert_eq!(detect_request_scheme(&req), "http"); - - // Test X-Forwarded-Proto with HTTPS - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header("x-forwarded-proto", "https"); - assert_eq!(detect_request_scheme(&req), "https"); - - // Test X-Forwarded-Proto with HTTP - let mut req = Request::new(Method::GET, "http://test.example.com/page"); - req.set_header("x-forwarded-proto", "http"); - assert_eq!(detect_request_scheme(&req), "http"); - - // Test Fastly-SSL header - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header("fastly-ssl", "1"); - assert_eq!(detect_request_scheme(&req), "https"); - - // Test default to HTTP when no headers present - let req = Request::new(Method::GET, "https://test.example.com/page"); - assert_eq!(detect_request_scheme(&req), "http"); - - // Test priority: Forwarded takes precedence over X-Forwarded-Proto - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header("forwarded", "proto=https"); - req.set_header("x-forwarded-proto", "http"); - assert_eq!(detect_request_scheme(&req), "https"); - } - - #[test] - fn test_handle_publisher_request_extracts_headers() { - // Test that the function correctly extracts host and scheme from request headers - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header("host", "test.example.com"); - req.set_header("x-forwarded-proto", "https"); - - // Extract headers like the function does - let request_host = req - .get_header("host") - .map(|h| h.to_str().unwrap_or_default()) - .unwrap_or_default() - .to_string(); - - let request_scheme = req - .get_header("x-forwarded-proto") - .and_then(|h| h.to_str().ok()) - .unwrap_or("https") - .to_string(); - - assert_eq!(request_host, "test.example.com"); - assert_eq!(request_scheme, "https"); - } - - // Note: test_handle_publisher_request_default_https_scheme and test_handle_publisher_request_http_scheme - // were removed as they're redundant with test_detect_request_scheme which covers all scheme detection cases - #[test] fn test_content_type_detection() { // Test which content types should be processed diff --git a/crates/common/src/synthetic.rs b/crates/common/src/synthetic.rs index d84a95e..b60736c 100644 --- a/crates/common/src/synthetic.rs +++ b/crates/common/src/synthetic.rs @@ -14,6 +14,7 @@ use sha2::Sha256; use crate::constants::{HEADER_SYNTHETIC_PUB_USER_ID, HEADER_SYNTHETIC_TRUSTED_SERVER}; use crate::cookies::handle_request_cookies; use crate::error::TrustedServerError; +use crate::http_util::RequestInfo; use crate::settings::Settings; type HmacSha256 = Hmac; @@ -41,9 +42,13 @@ pub fn generate_synthetic_id( let auth_user_id = req .get_header(HEADER_SYNTHETIC_PUB_USER_ID) .map(|h| h.to_str().unwrap_or("anonymous")); - let publisher_domain = req - .get_header(header::HOST) - .map(|h| h.to_str().unwrap_or("unknown")); + // Use RequestInfo for consistent host extraction (respects X-Forwarded-Host) + let request_info = RequestInfo::from_request(req); + let publisher_domain = if request_info.host.is_empty() { + None + } else { + Some(request_info.host.as_str()) + }; let client_ip = req.get_client_ip_addr().map(|ip| ip.to_string()); let accept_language = req .get_header(header::ACCEPT_LANGUAGE) From eebf6c355d72658f6fd2ac39fb5e18d8524e3235 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:31:19 -0800 Subject: [PATCH 2/2] Fixed clippy warnings --- crates/common/src/publisher.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/common/src/publisher.rs b/crates/common/src/publisher.rs index 55f73b5..796728f 100644 --- a/crates/common/src/publisher.rs +++ b/crates/common/src/publisher.rs @@ -266,8 +266,8 @@ pub fn handle_publisher_request( content_encoding: &content_encoding, origin_host: &origin_host, origin_url: &settings.publisher.origin_url, - request_host: &request_host, - request_scheme: &request_scheme, + request_host, + request_scheme, settings, content_type: &content_type, integration_registry,