Skip to content
Open
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
119 changes: 106 additions & 13 deletions crates/common/src/http_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/IABTechLab/trusted-server/issues/409>.
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);
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion crates/common/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏕 camp site — The debug log below (lines 229–236) still formats x-forwarded-host and x-forwarded-proto, but after edge sanitization these are always None. Consider removing those two fields to reduce noise:

log::debug!(
    "Request info: host={}, scheme={} (Host: {:?})",
    request_host,
    request_scheme,
    req.get_header(header::HOST),
);

let request_info = RequestInfo::from_request(&req);
let request_host = &request_info.host;
let request_scheme = &request_info.scheme;
Expand Down
8 changes: 7 additions & 1 deletion crates/fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,8 +65,13 @@ async fn route_request(
settings: &Settings,
orchestrator: &AuctionOrchestrator,
integration_registry: &IntegrationRegistry,
req: Request,
mut req: Request,
) -> Result<Response, Error> {
// 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);

Expand Down
Loading