Skip to content

Commit 74a3cbd

Browse files
Strip spoofable forwarded headers at the Fastly edge entry point
Prevent X-Forwarded-Host / Forwarded header spoofing that allows attackers to hijack URL rewriting across HTML streaming, ad-proxy URLs, Prebid OpenRTB requests, and request signing payloads. Closes #409
1 parent 7a128d8 commit 74a3cbd

3 files changed

Lines changed: 114 additions & 15 deletions

File tree

crates/common/src/http_util.rs

Lines changed: 106 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,32 +26,66 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) {
2626
}
2727
}
2828

29+
/// Headers that clients can spoof to hijack URL rewriting.
30+
///
31+
/// On Fastly Compute the service is the edge — there is no upstream proxy that
32+
/// legitimately sets these. Stripping them forces [`RequestInfo::from_request`]
33+
/// to fall back to the trustworthy `Host` header and Fastly SDK TLS detection.
34+
const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[
35+
"forwarded",
36+
"x-forwarded-host",
37+
"x-forwarded-proto",
38+
"fastly-ssl",
39+
];
40+
41+
/// Strip forwarded headers that clients can spoof.
42+
///
43+
/// Call this at the edge entry point (before routing) to prevent
44+
/// `X-Forwarded-Host: evil.com` from hijacking all URL rewriting.
45+
/// See <https://github.com/IABTechLab/trusted-server/issues/409>.
46+
pub fn sanitize_forwarded_headers(req: &mut Request) {
47+
for header in SPOOFABLE_FORWARDED_HEADERS {
48+
if req.get_header(*header).is_some() {
49+
log::debug!("Stripped spoofable header: {}", header);
50+
req.remove_header(*header);
51+
}
52+
}
53+
}
54+
2955
/// Extracted request information for host rewriting.
3056
///
31-
/// This struct captures the effective host and scheme from an incoming request,
32-
/// accounting for proxy headers like `X-Forwarded-Host` and `X-Forwarded-Proto`.
57+
/// This struct captures the effective host and scheme from an incoming request.
58+
/// The parser checks forwarded headers (`Forwarded`, `X-Forwarded-Host`,
59+
/// `X-Forwarded-Proto`) as fallbacks, but on the Fastly edge
60+
/// [`sanitize_forwarded_headers`] strips those headers before this method is
61+
/// called, so the `Host` header and Fastly SDK TLS detection are the effective
62+
/// sources in production.
3363
#[derive(Debug, Clone)]
3464
pub struct RequestInfo {
35-
/// The effective host for URL rewriting (from Forwarded, X-Forwarded-Host, or Host header)
65+
/// The effective host for URL rewriting (typically the `Host` header after edge sanitization).
3666
pub host: String,
37-
/// The effective scheme (from TLS detection, Forwarded, X-Forwarded-Proto, or default)
67+
/// The effective scheme (typically from Fastly SDK TLS detection after edge sanitization).
3868
pub scheme: String,
3969
}
4070

4171
impl RequestInfo {
4272
/// Extract request info from a Fastly request.
4373
///
44-
/// Host priority:
45-
/// 1. `Forwarded` header (RFC 7239, `host=...`)
46-
/// 2. `X-Forwarded-Host` header (for chained proxy setups)
74+
/// Host fallback order (first present wins):
75+
/// 1. `Forwarded` header (`host=...`)
76+
/// 2. `X-Forwarded-Host`
4777
/// 3. `Host` header
4878
///
49-
/// Scheme priority:
50-
/// 1. Fastly SDK TLS detection (most reliable)
51-
/// 2. `Forwarded` header (RFC 7239, `proto=https`)
52-
/// 3. `X-Forwarded-Proto` header
53-
/// 4. `Fastly-SSL` header
54-
/// 5. Default to `http`
79+
/// Scheme fallback order:
80+
/// 1. Fastly SDK TLS detection
81+
/// 2. `Forwarded` header (`proto=...`)
82+
/// 3. `X-Forwarded-Proto`
83+
/// 4. `Fastly-SSL`
84+
/// 5. Default `http`
85+
///
86+
/// In production the forwarded headers are stripped by
87+
/// [`sanitize_forwarded_headers`] at the edge, so `Host` and SDK TLS
88+
/// detection are the only sources that fire.
5589
pub fn from_request(req: &Request) -> Self {
5690
let host = extract_request_host(req);
5791
let scheme = detect_request_scheme(req);
@@ -468,6 +502,65 @@ mod tests {
468502
);
469503
}
470504

505+
// Sanitization tests
506+
507+
#[test]
508+
fn sanitize_removes_all_spoofable_headers() {
509+
let mut req = Request::new(fastly::http::Method::GET, "https://example.com/page");
510+
req.set_header("host", "legit.example.com");
511+
req.set_header("forwarded", "host=evil.com;proto=https");
512+
req.set_header("x-forwarded-host", "evil.com");
513+
req.set_header("x-forwarded-proto", "https");
514+
req.set_header("fastly-ssl", "1");
515+
516+
sanitize_forwarded_headers(&mut req);
517+
518+
assert!(
519+
req.get_header("forwarded").is_none(),
520+
"should strip Forwarded header"
521+
);
522+
assert!(
523+
req.get_header("x-forwarded-host").is_none(),
524+
"should strip X-Forwarded-Host header"
525+
);
526+
assert!(
527+
req.get_header("x-forwarded-proto").is_none(),
528+
"should strip X-Forwarded-Proto header"
529+
);
530+
assert!(
531+
req.get_header("fastly-ssl").is_none(),
532+
"should strip Fastly-SSL header"
533+
);
534+
assert_eq!(
535+
req.get_header("host")
536+
.expect("should have Host header")
537+
.to_str()
538+
.expect("should be valid UTF-8"),
539+
"legit.example.com",
540+
"should preserve Host header"
541+
);
542+
}
543+
544+
#[test]
545+
fn sanitize_then_request_info_falls_back_to_host() {
546+
let mut req = Request::new(fastly::http::Method::GET, "https://example.com/page");
547+
req.set_header("host", "legit.example.com");
548+
req.set_header("x-forwarded-host", "evil.com");
549+
req.set_header("x-forwarded-proto", "http");
550+
551+
sanitize_forwarded_headers(&mut req);
552+
let info = RequestInfo::from_request(&req);
553+
554+
assert_eq!(
555+
info.host, "legit.example.com",
556+
"should fall back to Host header after sanitization"
557+
);
558+
assert_eq!(
559+
info.scheme, "http",
560+
"should default to http when forwarded proto is stripped and no TLS"
561+
);
562+
}
563+
471564
#[test]
472565
fn test_copy_custom_headers_filters_internal() {
473566
let mut req = Request::new(fastly::http::Method::GET, "https://example.com");

crates/common/src/publisher.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ pub fn handle_publisher_request(
221221
// Prebid.js requests are not intercepted here anymore. The HTML processor removes
222222
// publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled.
223223

224-
// Extract request host and scheme from headers (supports X-Forwarded-Host/Proto for chained proxies)
224+
// Extract request host and scheme (uses Host header and TLS detection after edge sanitization)
225225
let request_info = RequestInfo::from_request(&req);
226226
let request_host = &request_info.host;
227227
let request_scheme = &request_info.scheme;

crates/fastly/src/main.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use trusted_server_common::constants::{
1212
};
1313
use trusted_server_common::error::TrustedServerError;
1414
use trusted_server_common::geo::GeoInfo;
15+
use trusted_server_common::http_util::sanitize_forwarded_headers;
1516
use trusted_server_common::integrations::IntegrationRegistry;
1617
use trusted_server_common::proxy::{
1718
handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild,
@@ -64,8 +65,13 @@ async fn route_request(
6465
settings: &Settings,
6566
orchestrator: &AuctionOrchestrator,
6667
integration_registry: &IntegrationRegistry,
67-
req: Request,
68+
mut req: Request,
6869
) -> Result<Response, Error> {
70+
// Strip client-spoofable forwarded headers at the edge.
71+
// On Fastly this service IS the first proxy — these headers from
72+
// clients are untrusted and can hijack URL rewriting (see #409).
73+
sanitize_forwarded_headers(&mut req);
74+
6975
// Extract geo info before auth check or routing consumes the request
7076
let geo_info = GeoInfo::from_request(&req);
7177

0 commit comments

Comments
 (0)