@@ -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 ) ]
3464pub 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
4171impl 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" ) ;
0 commit comments