1use actix_web::{web, HttpRequest};
16
17use crate::AppState;
18
19fn clean_scheme(raw: &str) -> Option<&'static str> {
22 match raw.trim().to_ascii_lowercase().as_str() {
23 "https" => Some("https"),
24 "http" => Some("http"),
25 _ => None,
26 }
27}
28
29pub fn proxy_header_scheme(req: &HttpRequest) -> Option<&'static str> {
34 if let Some(v) = req
35 .headers()
36 .get("x-forwarded-proto")
37 .and_then(|v| v.to_str().ok())
38 {
39 if let Some(s) = v.split(',').next().and_then(clean_scheme) {
40 return Some(s);
41 }
42 }
43 let cf = req.headers().get("cf-visitor")?.to_str().ok()?;
44 if cf.len() > 256 {
45 return None;
46 }
47 let parsed: serde_json::Value = serde_json::from_str(cf).ok()?;
48 clean_scheme(parsed.get("scheme")?.as_str()?)
49}
50
51fn transport_scheme(req: &HttpRequest) -> &'static str {
53 if req.app_config().secure() {
54 "https"
55 } else {
56 "http"
57 }
58}
59
60pub fn effective_scheme_with(req: &HttpRequest, trust_proxy: bool) -> &'static str {
76 if trust_proxy {
77 if let Some(s) = proxy_header_scheme(req) {
78 return s;
79 }
80 }
81 transport_scheme(req)
82}
83
84pub fn effective_scheme(req: &HttpRequest) -> &'static str {
87 let trust = req
88 .app_data::<web::Data<AppState>>()
89 .map(|s| s.trust_proxy)
90 .unwrap_or(false);
91 effective_scheme_with(req, trust)
92}
93
94pub fn normalise_origin(raw: &str) -> Option<String> {
104 let raw = raw.trim();
105 let (scheme, rest) = raw.split_once("://")?;
106 let scheme = clean_scheme(scheme)?;
107 let authority = rest.split(['/', '?', '#']).next()?;
108 valid_authority(authority).then(|| format!("{scheme}://{}", authority.to_ascii_lowercase()))
109}
110
111fn valid_authority(a: &str) -> bool {
115 !a.is_empty()
116 && a.len() <= 255
117 && a.bytes()
118 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']'))
119}
120
121fn proxy_header_host(req: &HttpRequest) -> Option<String> {
124 let xfh = req
125 .headers()
126 .get("x-forwarded-host")
127 .and_then(|v| v.to_str().ok())
128 .and_then(|v| v.split(',').next())
129 .map(str::trim);
130 if let Some(h) = xfh.filter(|h| valid_authority(h)) {
131 return Some(h.to_string());
132 }
133 let fwd = req.headers().get("forwarded")?.to_str().ok()?;
134 let first = fwd.split(',').next()?;
135 first.split(';').find_map(|p| {
136 let (k, v) = p.split_once('=')?;
137 let v = v.trim().trim_matches('"');
138 (k.trim().eq_ignore_ascii_case("host") && valid_authority(v)).then(|| v.to_string())
139 })
140}
141
142fn raw_host(req: &HttpRequest) -> Option<String> {
144 req.headers()
145 .get("host")
146 .and_then(|v| v.to_str().ok())
147 .map(str::trim)
148 .filter(|h| valid_authority(h))
149 .map(str::to_string)
150 .or_else(|| {
151 req.uri()
152 .authority()
153 .map(|a| a.as_str().to_string())
154 .filter(|a| valid_authority(a))
155 })
156}
157
158pub fn host_with(req: &HttpRequest, public_origin: Option<&str>, trust_proxy: bool) -> String {
180 if let Some(o) = public_origin {
181 if let Some((_, a)) = o.split_once("://") {
182 return a.to_string();
183 }
184 }
185 if trust_proxy {
186 if let Some(h) = proxy_header_host(req) {
187 return h;
188 }
189 }
190 raw_host(req).unwrap_or_else(|| req.app_config().host().to_string())
192}
193
194pub fn request_host(req: &HttpRequest) -> String {
209 match req.app_data::<web::Data<AppState>>() {
210 Some(s) => host_with(req, s.public_origin().as_deref(), s.trust_proxy),
211 None => host_with(req, None, false),
212 }
213}
214
215pub fn request_origin(req: &HttpRequest) -> String {
218 if let Some(o) = req
219 .app_data::<web::Data<AppState>>()
220 .and_then(|s| s.public_origin())
221 {
222 return o;
223 }
224 format!("{}://{}", effective_scheme(req), request_host(req))
225}
226
227pub fn request_url(req: &HttpRequest) -> String {
241 format!("{}{}", request_origin(req), req.uri())
242}
243
244pub fn host_allowed(host: &str, allowed: &[String]) -> bool {
256 if allowed.is_empty() {
257 return true;
258 }
259 let host = host.to_ascii_lowercase();
260 let bare = match host.rsplit_once(':') {
261 Some((h, p)) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => h,
262 _ => host.as_str(),
263 };
264 allowed.iter().any(|a| {
265 let a = a.trim().to_ascii_lowercase();
266 a == host || a == bare
267 })
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use actix_web::test::TestRequest;
274
275 fn req(headers: &[(&'static str, &'static str)]) -> HttpRequest {
276 let mut r = TestRequest::get().uri("/x");
277 for h in headers {
278 r = r.insert_header(*h);
279 }
280 r.to_http_request()
281 }
282
283 #[test]
284 fn untrusted_headers_are_ignored() {
285 let r = req(&[
286 ("x-forwarded-proto", "https"),
287 ("cf-visitor", r#"{"scheme":"https"}"#),
288 ]);
289 assert_eq!(effective_scheme_with(&r, false), "http");
290 }
291
292 #[test]
293 fn trusted_forwarded_proto_wins_over_cf_visitor() {
294 let r = req(&[
295 ("x-forwarded-proto", "http"),
296 ("cf-visitor", r#"{"scheme":"https"}"#),
297 ]);
298 assert_eq!(effective_scheme_with(&r, true), "http");
299 }
300
301 #[test]
302 fn trusted_cf_visitor_used_when_no_forwarded_proto() {
303 let r = req(&[("cf-visitor", r#"{"scheme":"https"}"#)]);
304 assert_eq!(effective_scheme_with(&r, true), "https");
305 }
306
307 #[test]
308 fn forwarded_proto_comma_list_takes_first_hop() {
309 let r = req(&[("x-forwarded-proto", "https, http")]);
310 assert_eq!(effective_scheme_with(&r, true), "https");
311 }
312
313 #[test]
314 fn garbage_values_fall_back_to_transport() {
315 for h in [
316 ("x-forwarded-proto", "gopher"),
317 ("cf-visitor", "not json"),
318 ("cf-visitor", r#"{"scheme":"ftp"}"#),
319 ("cf-visitor", r#"{"scheme":7}"#),
320 ] {
321 assert_eq!(effective_scheme_with(&req(&[h]), true), "http", "{h:?}");
322 }
323 }
324
325 #[test]
326 fn unusable_forwarded_proto_falls_through_to_cf_visitor() {
327 let r = req(&[
328 ("x-forwarded-proto", "bogus"),
329 ("cf-visitor", r#"{"scheme":"https"}"#),
330 ]);
331 assert_eq!(effective_scheme_with(&r, true), "https");
332 }
333
334 #[test]
335 fn forwarded_host_is_ignored_without_trust_proxy() {
336 let r = req(&[
337 ("host", "pod.example"),
338 ("x-forwarded-host", "evil.example"),
339 ]);
340 assert_eq!(host_with(&r, None, false), "pod.example");
341 let r = req(&[("host", "pod.example"), ("forwarded", "host=evil.example")]);
342 assert_eq!(host_with(&r, None, false), "pod.example");
343 }
344
345 #[test]
346 fn forwarded_host_is_honoured_behind_trust_proxy() {
347 let r = req(&[
348 ("host", "10.0.0.5"),
349 ("x-forwarded-host", "pod.example, other"),
350 ]);
351 assert_eq!(host_with(&r, None, true), "pod.example");
352 let r = req(&[
353 ("host", "10.0.0.5"),
354 ("forwarded", "for=1.2.3.4;host=\"pod.example\""),
355 ]);
356 assert_eq!(host_with(&r, None, true), "pod.example");
357 }
358
359 #[test]
360 fn configured_origin_beats_every_header() {
361 let r = req(&[
362 ("host", "evil.example"),
363 ("x-forwarded-host", "evil.example"),
364 ]);
365 assert_eq!(
366 host_with(&r, Some("https://pod.example"), true),
367 "pod.example"
368 );
369 }
370
371 #[test]
372 fn hostile_host_values_are_not_reflected() {
373 for bad in ["a/b", "user@evil.example", "a b", "evil.example/path?x"] {
374 let r = req(&[("x-forwarded-host", bad)]);
375 assert_eq!(host_with(&r, None, true), r.app_config().host(), "{bad}");
376 }
377 }
378
379 #[test]
380 fn allow_list_matches_host_and_authority() {
381 let a = vec!["pod.example".to_string(), "127.0.0.1:3000".to_string()];
382 assert!(host_allowed("pod.example", &a));
383 assert!(host_allowed("pod.example:8443", &a));
384 assert!(host_allowed("127.0.0.1:3000", &a));
385 assert!(!host_allowed("127.0.0.1:4000", &a));
386 assert!(!host_allowed("evil.example", &a));
387 }
388
389 #[test]
390 fn oversized_cf_visitor_is_ignored() {
391 let big = format!(r#"{{"scheme":"https","pad":"{}"}}"#, "a".repeat(400));
392 let r = TestRequest::get()
393 .insert_header(("cf-visitor", big))
394 .to_http_request();
395 assert_eq!(effective_scheme_with(&r, true), "http");
396 }
397}