diff --git a/crates/common/src/http_util.rs b/crates/common/src/http_util.rs index 931c894f..5b3c4b8b 100644 --- a/crates/common/src/http_util.rs +++ b/crates/common/src/http_util.rs @@ -26,32 +26,66 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) { } } +/// Headers that clients can spoof to hijack URL rewriting. +/// +/// On Fastly Compute the service is the edge — there is no upstream proxy that +/// legitimately sets these. Stripping them forces [`RequestInfo::from_request`] +/// to fall back to the trustworthy `Host` header and Fastly SDK TLS detection. +const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ + "forwarded", + "x-forwarded-host", + "x-forwarded-proto", + "fastly-ssl", +]; + +/// Strip forwarded headers that clients can spoof. +/// +/// Call this at the edge entry point (before routing) to prevent +/// `X-Forwarded-Host: evil.com` from hijacking all URL rewriting. +/// See . +pub fn sanitize_forwarded_headers(req: &mut Request) { + for header in SPOOFABLE_FORWARDED_HEADERS { + if req.get_header(*header).is_some() { + log::debug!("Stripped spoofable header: {}", header); + req.remove_header(*header); + } + } +} + /// 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`. +/// This struct captures the effective host and scheme from an incoming request. +/// The parser checks forwarded headers (`Forwarded`, `X-Forwarded-Host`, +/// `X-Forwarded-Proto`) as fallbacks, but on the Fastly edge +/// [`sanitize_forwarded_headers`] strips those headers before this method is +/// called, so the `Host` header and Fastly SDK TLS detection are the effective +/// sources in production. #[derive(Debug, Clone)] pub struct RequestInfo { - /// The effective host for URL rewriting (from Forwarded, X-Forwarded-Host, or Host header) + /// The effective host for URL rewriting (typically the `Host` header after edge sanitization). pub host: String, - /// The effective scheme (from TLS detection, Forwarded, X-Forwarded-Proto, or default) + /// The effective scheme (typically from Fastly SDK TLS detection after edge sanitization). 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) + /// Host fallback order (first present wins): + /// 1. `Forwarded` header (`host=...`) + /// 2. `X-Forwarded-Host` /// 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` + /// Scheme fallback order: + /// 1. Fastly SDK TLS detection + /// 2. `Forwarded` header (`proto=...`) + /// 3. `X-Forwarded-Proto` + /// 4. `Fastly-SSL` + /// 5. Default `http` + /// + /// In production the forwarded headers are stripped by + /// [`sanitize_forwarded_headers`] at the edge, so `Host` and SDK TLS + /// detection are the only sources that fire. pub fn from_request(req: &Request) -> Self { let host = extract_request_host(req); let scheme = detect_request_scheme(req); @@ -468,6 +502,65 @@ mod tests { ); } + // Sanitization tests + + #[test] + fn sanitize_removes_all_spoofable_headers() { + let mut req = Request::new(fastly::http::Method::GET, "https://example.com/page"); + req.set_header("host", "legit.example.com"); + req.set_header("forwarded", "host=evil.com;proto=https"); + req.set_header("x-forwarded-host", "evil.com"); + req.set_header("x-forwarded-proto", "https"); + req.set_header("fastly-ssl", "1"); + + sanitize_forwarded_headers(&mut req); + + assert!( + req.get_header("forwarded").is_none(), + "should strip Forwarded header" + ); + assert!( + req.get_header("x-forwarded-host").is_none(), + "should strip X-Forwarded-Host header" + ); + assert!( + req.get_header("x-forwarded-proto").is_none(), + "should strip X-Forwarded-Proto header" + ); + assert!( + req.get_header("fastly-ssl").is_none(), + "should strip Fastly-SSL header" + ); + assert_eq!( + req.get_header("host") + .expect("should have Host header") + .to_str() + .expect("should be valid UTF-8"), + "legit.example.com", + "should preserve Host header" + ); + } + + #[test] + fn sanitize_then_request_info_falls_back_to_host() { + let mut req = Request::new(fastly::http::Method::GET, "https://example.com/page"); + req.set_header("host", "legit.example.com"); + req.set_header("x-forwarded-host", "evil.com"); + req.set_header("x-forwarded-proto", "http"); + + sanitize_forwarded_headers(&mut req); + let info = RequestInfo::from_request(&req); + + assert_eq!( + info.host, "legit.example.com", + "should fall back to Host header after sanitization" + ); + assert_eq!( + info.scheme, "http", + "should default to http when forwarded proto is stripped and no TLS" + ); + } + #[test] fn test_copy_custom_headers_filters_internal() { let mut req = Request::new(fastly::http::Method::GET, "https://example.com"); diff --git a/crates/common/src/publisher.rs b/crates/common/src/publisher.rs index 7c86d06d..2850ab17 100644 --- a/crates/common/src/publisher.rs +++ b/crates/common/src/publisher.rs @@ -221,7 +221,7 @@ pub fn handle_publisher_request( // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. - // Extract request host and scheme from headers (supports X-Forwarded-Host/Proto for chained proxies) + // Extract request host and scheme (uses Host header and TLS detection after edge sanitization) let request_info = RequestInfo::from_request(&req); let request_host = &request_info.host; let request_scheme = &request_info.scheme; diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 8cd5ba09..e7cb862c 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -12,6 +12,7 @@ use trusted_server_common::constants::{ }; use trusted_server_common::error::TrustedServerError; use trusted_server_common::geo::GeoInfo; +use trusted_server_common::http_util::sanitize_forwarded_headers; use trusted_server_common::integrations::IntegrationRegistry; use trusted_server_common::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, @@ -64,8 +65,13 @@ async fn route_request( settings: &Settings, orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, - req: Request, + mut req: Request, ) -> Result { + // Strip client-spoofable forwarded headers at the edge. + // On Fastly this service IS the first proxy — these headers from + // clients are untrusted and can hijack URL rewriting (see #409). + sanitize_forwarded_headers(&mut req); + // Extract geo info before auth check or routing consumes the request let geo_info = GeoInfo::from_request(&req);