use actix_web::{web, HttpRequest};
use crate::AppState;
fn clean_scheme(raw: &str) -> Option<&'static str> {
match raw.trim().to_ascii_lowercase().as_str() {
"https" => Some("https"),
"http" => Some("http"),
_ => None,
}
}
pub fn proxy_header_scheme(req: &HttpRequest) -> Option<&'static str> {
if let Some(v) = req
.headers()
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
{
if let Some(s) = v.split(',').next().and_then(clean_scheme) {
return Some(s);
}
}
let cf = req.headers().get("cf-visitor")?.to_str().ok()?;
if cf.len() > 256 {
return None;
}
let parsed: serde_json::Value = serde_json::from_str(cf).ok()?;
clean_scheme(parsed.get("scheme")?.as_str()?)
}
fn transport_scheme(req: &HttpRequest) -> &'static str {
if req.app_config().secure() {
"https"
} else {
"http"
}
}
pub fn effective_scheme_with(req: &HttpRequest, trust_proxy: bool) -> &'static str {
if trust_proxy {
if let Some(s) = proxy_header_scheme(req) {
return s;
}
}
transport_scheme(req)
}
pub fn effective_scheme(req: &HttpRequest) -> &'static str {
let trust = req
.app_data::<web::Data<AppState>>()
.map(|s| s.trust_proxy)
.unwrap_or(false);
effective_scheme_with(req, trust)
}
pub fn normalise_origin(raw: &str) -> Option<String> {
let raw = raw.trim();
let (scheme, rest) = raw.split_once("://")?;
let scheme = clean_scheme(scheme)?;
let authority = rest.split(['/', '?', '#']).next()?;
valid_authority(authority).then(|| format!("{scheme}://{}", authority.to_ascii_lowercase()))
}
fn valid_authority(a: &str) -> bool {
!a.is_empty()
&& a.len() <= 255
&& a.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']'))
}
fn proxy_header_host(req: &HttpRequest) -> Option<String> {
let xfh = req
.headers()
.get("x-forwarded-host")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim);
if let Some(h) = xfh.filter(|h| valid_authority(h)) {
return Some(h.to_string());
}
let fwd = req.headers().get("forwarded")?.to_str().ok()?;
let first = fwd.split(',').next()?;
first.split(';').find_map(|p| {
let (k, v) = p.split_once('=')?;
let v = v.trim().trim_matches('"');
(k.trim().eq_ignore_ascii_case("host") && valid_authority(v)).then(|| v.to_string())
})
}
fn raw_host(req: &HttpRequest) -> Option<String> {
req.headers()
.get("host")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|h| valid_authority(h))
.map(str::to_string)
.or_else(|| {
req.uri()
.authority()
.map(|a| a.as_str().to_string())
.filter(|a| valid_authority(a))
})
}
pub fn host_with(req: &HttpRequest, public_origin: Option<&str>, trust_proxy: bool) -> String {
if let Some(o) = public_origin {
if let Some((_, a)) = o.split_once("://") {
return a.to_string();
}
}
if trust_proxy {
if let Some(h) = proxy_header_host(req) {
return h;
}
}
raw_host(req).unwrap_or_else(|| req.app_config().host().to_string())
}
pub fn request_host(req: &HttpRequest) -> String {
match req.app_data::<web::Data<AppState>>() {
Some(s) => host_with(req, s.public_origin().as_deref(), s.trust_proxy),
None => host_with(req, None, false),
}
}
pub fn request_origin(req: &HttpRequest) -> String {
if let Some(o) = req
.app_data::<web::Data<AppState>>()
.and_then(|s| s.public_origin())
{
return o;
}
format!("{}://{}", effective_scheme(req), request_host(req))
}
pub fn request_url(req: &HttpRequest) -> String {
format!("{}{}", request_origin(req), req.uri())
}
pub fn host_allowed(host: &str, allowed: &[String]) -> bool {
if allowed.is_empty() {
return true;
}
let host = host.to_ascii_lowercase();
let bare = match host.rsplit_once(':') {
Some((h, p)) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => h,
_ => host.as_str(),
};
allowed.iter().any(|a| {
let a = a.trim().to_ascii_lowercase();
a == host || a == bare
})
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::test::TestRequest;
fn req(headers: &[(&'static str, &'static str)]) -> HttpRequest {
let mut r = TestRequest::get().uri("/x");
for h in headers {
r = r.insert_header(*h);
}
r.to_http_request()
}
#[test]
fn untrusted_headers_are_ignored() {
let r = req(&[
("x-forwarded-proto", "https"),
("cf-visitor", r#"{"scheme":"https"}"#),
]);
assert_eq!(effective_scheme_with(&r, false), "http");
}
#[test]
fn trusted_forwarded_proto_wins_over_cf_visitor() {
let r = req(&[
("x-forwarded-proto", "http"),
("cf-visitor", r#"{"scheme":"https"}"#),
]);
assert_eq!(effective_scheme_with(&r, true), "http");
}
#[test]
fn trusted_cf_visitor_used_when_no_forwarded_proto() {
let r = req(&[("cf-visitor", r#"{"scheme":"https"}"#)]);
assert_eq!(effective_scheme_with(&r, true), "https");
}
#[test]
fn forwarded_proto_comma_list_takes_first_hop() {
let r = req(&[("x-forwarded-proto", "https, http")]);
assert_eq!(effective_scheme_with(&r, true), "https");
}
#[test]
fn garbage_values_fall_back_to_transport() {
for h in [
("x-forwarded-proto", "gopher"),
("cf-visitor", "not json"),
("cf-visitor", r#"{"scheme":"ftp"}"#),
("cf-visitor", r#"{"scheme":7}"#),
] {
assert_eq!(effective_scheme_with(&req(&[h]), true), "http", "{h:?}");
}
}
#[test]
fn unusable_forwarded_proto_falls_through_to_cf_visitor() {
let r = req(&[
("x-forwarded-proto", "bogus"),
("cf-visitor", r#"{"scheme":"https"}"#),
]);
assert_eq!(effective_scheme_with(&r, true), "https");
}
#[test]
fn forwarded_host_is_ignored_without_trust_proxy() {
let r = req(&[
("host", "pod.example"),
("x-forwarded-host", "evil.example"),
]);
assert_eq!(host_with(&r, None, false), "pod.example");
let r = req(&[("host", "pod.example"), ("forwarded", "host=evil.example")]);
assert_eq!(host_with(&r, None, false), "pod.example");
}
#[test]
fn forwarded_host_is_honoured_behind_trust_proxy() {
let r = req(&[
("host", "10.0.0.5"),
("x-forwarded-host", "pod.example, other"),
]);
assert_eq!(host_with(&r, None, true), "pod.example");
let r = req(&[
("host", "10.0.0.5"),
("forwarded", "for=1.2.3.4;host=\"pod.example\""),
]);
assert_eq!(host_with(&r, None, true), "pod.example");
}
#[test]
fn configured_origin_beats_every_header() {
let r = req(&[
("host", "evil.example"),
("x-forwarded-host", "evil.example"),
]);
assert_eq!(
host_with(&r, Some("https://pod.example"), true),
"pod.example"
);
}
#[test]
fn hostile_host_values_are_not_reflected() {
for bad in ["a/b", "user@evil.example", "a b", "evil.example/path?x"] {
let r = req(&[("x-forwarded-host", bad)]);
assert_eq!(host_with(&r, None, true), r.app_config().host(), "{bad}");
}
}
#[test]
fn allow_list_matches_host_and_authority() {
let a = vec!["pod.example".to_string(), "127.0.0.1:3000".to_string()];
assert!(host_allowed("pod.example", &a));
assert!(host_allowed("pod.example:8443", &a));
assert!(host_allowed("127.0.0.1:3000", &a));
assert!(!host_allowed("127.0.0.1:4000", &a));
assert!(!host_allowed("evil.example", &a));
}
#[test]
fn oversized_cf_visitor_is_ignored() {
let big = format!(r#"{{"scheme":"https","pad":"{}"}}"#, "a".repeat(400));
let r = TestRequest::get()
.insert_header(("cf-visitor", big))
.to_http_request();
assert_eq!(effective_scheme_with(&r, true), "http");
}
}