@@ -6,6 +6,157 @@ use sha2::{Digest, Sha256};
66
77use crate :: settings:: Settings ;
88
9+ /// Extracted request information for host rewriting.
10+ ///
11+ /// This struct captures the effective host and scheme from an incoming request,
12+ /// accounting for proxy headers like `X-Forwarded-Host` and `X-Forwarded-Proto`.
13+ #[ derive( Debug , Clone ) ]
14+ pub struct RequestInfo {
15+ /// The effective host for URL rewriting (from Forwarded, X-Forwarded-Host, or Host header)
16+ pub host : String ,
17+ /// The effective scheme (from TLS detection, Forwarded, X-Forwarded-Proto, or default)
18+ pub scheme : String ,
19+ }
20+
21+ impl RequestInfo {
22+ /// Extract request info from a Fastly request.
23+ ///
24+ /// Host priority:
25+ /// 1. `Forwarded` header (RFC 7239, `host=...`)
26+ /// 2. `X-Forwarded-Host` header (for chained proxy setups)
27+ /// 3. `Host` header
28+ ///
29+ /// Scheme priority:
30+ /// 1. Fastly SDK TLS detection (most reliable)
31+ /// 2. `Forwarded` header (RFC 7239, `proto=https`)
32+ /// 3. `X-Forwarded-Proto` header
33+ /// 4. `Fastly-SSL` header
34+ /// 5. Default to `http`
35+ pub fn from_request ( req : & Request ) -> Self {
36+ let host = extract_request_host ( req) ;
37+ let scheme = detect_request_scheme ( req) ;
38+
39+ Self { host, scheme }
40+ }
41+ }
42+
43+ fn extract_request_host ( req : & Request ) -> String {
44+ req. get_header ( "forwarded" )
45+ . and_then ( |h| h. to_str ( ) . ok ( ) )
46+ . and_then ( |value| parse_forwarded_param ( value, "host" ) )
47+ . or_else ( || {
48+ req. get_header ( "x-forwarded-host" )
49+ . and_then ( |h| h. to_str ( ) . ok ( ) )
50+ . and_then ( parse_list_header_value)
51+ } )
52+ . or_else ( || req. get_header ( header:: HOST ) . and_then ( |h| h. to_str ( ) . ok ( ) ) )
53+ . unwrap_or_default ( )
54+ . to_string ( )
55+ }
56+
57+ fn parse_forwarded_param < ' a > ( forwarded : & ' a str , param : & str ) -> Option < & ' a str > {
58+ for entry in forwarded. split ( ',' ) {
59+ for part in entry. split ( ';' ) {
60+ let mut iter = part. splitn ( 2 , '=' ) ;
61+ let key = iter. next ( ) . unwrap_or ( "" ) . trim ( ) ;
62+ let value = iter. next ( ) . unwrap_or ( "" ) . trim ( ) ;
63+ if key. is_empty ( ) || value. is_empty ( ) {
64+ continue ;
65+ }
66+ if key. eq_ignore_ascii_case ( param) {
67+ let value = strip_quotes ( value) ;
68+ if !value. is_empty ( ) {
69+ return Some ( value) ;
70+ }
71+ }
72+ }
73+ }
74+ None
75+ }
76+
77+ fn parse_list_header_value ( value : & str ) -> Option < & str > {
78+ value
79+ . split ( ',' )
80+ . map ( str:: trim)
81+ . find ( |part| !part. is_empty ( ) )
82+ . map ( strip_quotes)
83+ . filter ( |part| !part. is_empty ( ) )
84+ }
85+
86+ fn strip_quotes ( value : & str ) -> & str {
87+ let trimmed = value. trim ( ) ;
88+ if trimmed. len ( ) >= 2 && trimmed. starts_with ( '"' ) && trimmed. ends_with ( '"' ) {
89+ & trimmed[ 1 ..trimmed. len ( ) - 1 ]
90+ } else {
91+ trimmed
92+ }
93+ }
94+
95+ fn normalize_scheme ( value : & str ) -> Option < String > {
96+ let scheme = value. trim ( ) . to_ascii_lowercase ( ) ;
97+ if scheme == "https" || scheme == "http" {
98+ Some ( scheme)
99+ } else {
100+ None
101+ }
102+ }
103+
104+ /// Detects the request scheme (HTTP or HTTPS) using Fastly SDK methods and headers.
105+ ///
106+ /// Tries multiple methods in order of reliability:
107+ /// 1. Fastly SDK TLS detection methods (most reliable)
108+ /// 2. Forwarded header (RFC 7239)
109+ /// 3. X-Forwarded-Proto header
110+ /// 4. Fastly-SSL header (least reliable, can be spoofed)
111+ /// 5. Default to HTTP
112+ fn detect_request_scheme ( req : & Request ) -> String {
113+ // 1. First try Fastly SDK's built-in TLS detection methods
114+ if let Some ( tls_protocol) = req. get_tls_protocol ( ) {
115+ log:: debug!( "TLS protocol detected: {}" , tls_protocol) ;
116+ return "https" . to_string ( ) ;
117+ }
118+
119+ // Also check TLS cipher - if present, connection is HTTPS
120+ if req. get_tls_cipher_openssl_name ( ) . is_some ( ) {
121+ log:: debug!( "TLS cipher detected, using HTTPS" ) ;
122+ return "https" . to_string ( ) ;
123+ }
124+
125+ // 2. Try the Forwarded header (RFC 7239)
126+ if let Some ( forwarded) = req. get_header ( "forwarded" ) {
127+ if let Ok ( forwarded_str) = forwarded. to_str ( ) {
128+ if let Some ( proto) = parse_forwarded_param ( forwarded_str, "proto" ) {
129+ if let Some ( scheme) = normalize_scheme ( proto) {
130+ return scheme;
131+ }
132+ }
133+ }
134+ }
135+
136+ // 3. Try X-Forwarded-Proto header
137+ if let Some ( proto) = req. get_header ( "x-forwarded-proto" ) {
138+ if let Ok ( proto_str) = proto. to_str ( ) {
139+ if let Some ( value) = parse_list_header_value ( proto_str) {
140+ if let Some ( scheme) = normalize_scheme ( value) {
141+ return scheme;
142+ }
143+ }
144+ }
145+ }
146+
147+ // 4. Check Fastly-SSL header (can be spoofed by clients, use as last resort)
148+ if let Some ( ssl) = req. get_header ( "fastly-ssl" ) {
149+ if let Ok ( ssl_str) = ssl. to_str ( ) {
150+ if ssl_str == "1" || ssl_str. to_lowercase ( ) == "true" {
151+ return "https" . to_string ( ) ;
152+ }
153+ }
154+ }
155+
156+ // Default to HTTP
157+ "http" . to_string ( )
158+ }
159+
9160/// Build a static text response with strong `ETag` and standard caching headers.
10161/// Handles If-None-Match to return 304 when appropriate.
11162pub fn serve_static_with_etag ( body : & str , req : & Request , content_type : & str ) -> Response {
@@ -175,4 +326,118 @@ mod tests {
175326 & t1
176327 ) ) ;
177328 }
329+
330+ // RequestInfo tests
331+
332+ #[ test]
333+ fn test_request_info_from_host_header ( ) {
334+ let mut req = Request :: new ( fastly:: http:: Method :: GET , "https://test.example.com/page" ) ;
335+ req. set_header ( "host" , "test.example.com" ) ;
336+
337+ let info = RequestInfo :: from_request ( & req) ;
338+ assert_eq ! (
339+ info. host, "test.example.com" ,
340+ "Host should use Host header when forwarded headers are missing"
341+ ) ;
342+ // No TLS or forwarded headers, defaults to http.
343+ assert_eq ! (
344+ info. scheme, "http" ,
345+ "Scheme should default to http without TLS or forwarded headers"
346+ ) ;
347+ }
348+
349+ #[ test]
350+ fn test_request_info_x_forwarded_host_precedence ( ) {
351+ let mut req = Request :: new ( fastly:: http:: Method :: GET , "https://test.example.com/page" ) ;
352+ req. set_header ( "host" , "internal-proxy.local" ) ;
353+ req. set_header ( "x-forwarded-host" , "public.example.com, proxy.local" ) ;
354+
355+ let info = RequestInfo :: from_request ( & req) ;
356+ assert_eq ! (
357+ info. host, "public.example.com" ,
358+ "Host should prefer X-Forwarded-Host over Host"
359+ ) ;
360+ }
361+
362+ #[ test]
363+ fn test_request_info_scheme_from_x_forwarded_proto ( ) {
364+ let mut req = Request :: new ( fastly:: http:: Method :: GET , "https://test.example.com/page" ) ;
365+ req. set_header ( "host" , "test.example.com" ) ;
366+ req. set_header ( "x-forwarded-proto" , "https, http" ) ;
367+
368+ let info = RequestInfo :: from_request ( & req) ;
369+ assert_eq ! (
370+ info. scheme, "https" ,
371+ "Scheme should prefer the first X-Forwarded-Proto value"
372+ ) ;
373+
374+ // Test HTTP
375+ let mut req = Request :: new ( fastly:: http:: Method :: GET , "http://test.example.com/page" ) ;
376+ req. set_header ( "host" , "test.example.com" ) ;
377+ req. set_header ( "x-forwarded-proto" , "http" ) ;
378+
379+ let info = RequestInfo :: from_request ( & req) ;
380+ assert_eq ! (
381+ info. scheme, "http" ,
382+ "Scheme should use the X-Forwarded-Proto value when present"
383+ ) ;
384+ }
385+
386+ #[ test]
387+ fn request_info_forwarded_header_precedence ( ) {
388+ // Forwarded header takes precedence over X-Forwarded-Proto
389+ let mut req = Request :: new ( fastly:: http:: Method :: GET , "https://test.example.com/page" ) ;
390+ req. set_header (
391+ "forwarded" ,
392+ "for=192.0.2.60;proto=\" HTTPS\" ;host=\" public.example.com:443\" " ,
393+ ) ;
394+ req. set_header ( "host" , "internal-proxy.local" ) ;
395+ req. set_header ( "x-forwarded-host" , "proxy.local" ) ;
396+ req. set_header ( "x-forwarded-proto" , "http" ) ;
397+
398+ let info = RequestInfo :: from_request ( & req) ;
399+ assert_eq ! (
400+ info. host, "public.example.com:443" ,
401+ "Host should prefer Forwarded host over X-Forwarded-Host"
402+ ) ;
403+ assert_eq ! (
404+ info. scheme, "https" ,
405+ "Scheme should prefer Forwarded proto over X-Forwarded-Proto"
406+ ) ;
407+ }
408+
409+ #[ test]
410+ fn test_request_info_scheme_from_fastly_ssl ( ) {
411+ let mut req = Request :: new ( fastly:: http:: Method :: GET , "https://test.example.com/page" ) ;
412+ req. set_header ( "fastly-ssl" , "1" ) ;
413+
414+ let info = RequestInfo :: from_request ( & req) ;
415+ assert_eq ! (
416+ info. scheme, "https" ,
417+ "Scheme should fall back to Fastly-SSL when other signals are missing"
418+ ) ;
419+ }
420+
421+ #[ test]
422+ fn test_request_info_chained_proxy_scenario ( ) {
423+ // Simulate: Client (HTTPS) -> Proxy A -> Trusted Server (HTTP internally)
424+ // Proxy A sets X-Forwarded-Host and X-Forwarded-Proto
425+ let mut req = Request :: new (
426+ fastly:: http:: Method :: GET ,
427+ "http://trusted-server.internal/page" ,
428+ ) ;
429+ req. set_header ( "host" , "trusted-server.internal" ) ;
430+ req. set_header ( "x-forwarded-host" , "public.example.com" ) ;
431+ req. set_header ( "x-forwarded-proto" , "https" ) ;
432+
433+ let info = RequestInfo :: from_request ( & req) ;
434+ assert_eq ! (
435+ info. host, "public.example.com" ,
436+ "Host should use X-Forwarded-Host in chained proxy scenarios"
437+ ) ;
438+ assert_eq ! (
439+ info. scheme, "https" ,
440+ "Scheme should use X-Forwarded-Proto in chained proxy scenarios"
441+ ) ;
442+ }
178443}
0 commit comments