Skip to content

Commit 0346c80

Browse files
authored
Allow to specify multiple Prebid script handlers (#169)
1 parent 2074582 commit 0346c80

7 files changed

Lines changed: 519 additions & 279 deletions

File tree

crates/common/src/http_util.rs

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,157 @@ use sha2::{Digest, Sha256};
66

77
use 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.
11162
pub 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

Comments
 (0)