This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+516
View File
@@ -0,0 +1,516 @@
//! Single trust boundary for TCP, TLS and QUIC. Incoming proxy headers are never
//! authority for routing, listener selection or identity without a trusted peer.
use anyhow::{ensure, Context as _, Result};
use http::{HeaderMap, HeaderValue, Uri};
use ipnet::IpNet;
use serde::{Deserialize, Serialize};
use std::{
net::{IpAddr, SocketAddr},
sync::LazyLock,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub trusted_proxies: Vec<String>,
/// Enable only when the trusted last hop sanitizes X-Forwarded-Proto.
pub trust_forwarded_proto: bool,
}
#[derive(Debug)]
pub struct Compiled {
networks: Vec<IpNet>,
trust_proto: bool,
}
#[derive(Debug, Clone)]
pub struct Context {
pub client_ip: IpAddr,
pub scheme: &'static str,
pub original_url: String,
values: HeaderMap,
}
impl Settings {
pub fn compile(&self) -> Result<Compiled> {
ensure!(
self.trusted_proxies.len() <= 32,
"at most 32 trusted proxy CIDRs"
);
let networks = self
.trusted_proxies
.iter()
.map(|value| {
ensure!(value.len() <= 64, "trusted proxy CIDR is too long");
let net: IpNet = value.parse().context("invalid trusted proxy CIDR")?;
ensure!(
net.prefix_len() != 0,
"trusting every address is not permitted; list your actual proxy networks"
);
Ok(net)
})
.collect::<Result<Vec<_>>>()?;
Ok(Compiled {
networks,
trust_proto: self.trust_forwarded_proto,
})
}
}
fn canonical(ip: IpAddr) -> IpAddr {
crate::vhost::canonical_ip(ip)
}
const CLOUDFLARE_CIDRS: &[&str] = &[
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"104.16.0.0/13",
"104.24.0.0/14",
"108.162.192.0/18",
"131.0.72.0/22",
"141.101.64.0/18",
"162.158.0.0/15",
"172.64.0.0/13",
"173.245.48.0/20",
"188.114.96.0/20",
"190.93.240.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"2400:cb00::/32",
"2606:4700::/32",
"2803:f800::/32",
"2405:b500::/32",
"2405:8100::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
];
static CLOUDFLARE_NETWORKS: LazyLock<Vec<IpNet>> = LazyLock::new(|| {
CLOUDFLARE_CIDRS
.iter()
.map(|value| value.parse().expect("static Cloudflare CIDR"))
.collect()
});
/// Return CF-Connecting-IP only when the TCP peer itself is in Cloudflare's
/// published edge ranges. The header is otherwise untrusted user input.
pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option<IpAddr> {
let peer = canonical(peer.ip());
if !CLOUDFLARE_NETWORKS
.iter()
.any(|network| network.contains(&peer))
{
return None;
}
let mut values = headers.get_all("cf-connecting-ip").iter();
let value = values.next()?;
if values.next().is_some() {
return None;
}
value
.to_str()
.ok()?
.trim()
.parse::<IpAddr>()
.ok()
.map(canonical)
}
fn parse_node(value: &str) -> Option<IpAddr> {
let value = value.trim();
let value = if value.starts_with('"') {
value.strip_prefix('"')?.strip_suffix('"')?
} else {
value
};
if value.contains('\\') || value.contains('%') {
return None;
}
value
.parse::<IpAddr>()
.ok()
.or_else(|| value.parse::<SocketAddr>().ok().map(|address| address.ip()))
.or_else(|| {
value
.strip_prefix('[')?
.strip_suffix(']')?
.parse::<IpAddr>()
.ok()
})
.map(canonical)
}
fn chain(headers: &HeaderMap) -> Option<Vec<IpAddr>> {
let xff = headers.contains_key("x-forwarded-for");
let name = if xff { "x-forwarded-for" } else { "forwarded" };
let mut result = Vec::new();
let mut bytes = 0;
for value in headers.get_all(name) {
bytes += value.as_bytes().len();
if bytes > 4096 {
return None;
}
for item in value.to_str().ok()?.split(',') {
if result.len() >= 32 {
return None;
}
let ip = if xff {
parse_node(item)?
} else {
let mut found = None;
for parameter in item.split(';') {
let (key, value) = parameter.split_once('=')?;
if key.trim().eq_ignore_ascii_case("for") {
if found.is_some() {
return None;
}
found = Some(parse_node(value)?);
}
}
found?
};
result.push(ip);
}
}
Some(result)
}
fn node(ip: IpAddr) -> String {
match ip {
IpAddr::V4(_) => ip.to_string(),
IpAddr::V6(_) => format!("\"[{ip}]\""),
}
}
impl Compiled {
pub fn trusted(&self, ip: IpAddr) -> bool {
self.networks.iter().any(|net| net.contains(&canonical(ip)))
}
/// Whether this peer may define the externally visible request scheme.
/// Keeping this separate from `trusted` preserves the explicit
/// `trust_forwarded_proto` opt-in used by the public proxy path.
pub fn trusted_forwarded_proto(&self, ip: IpAddr) -> bool {
self.trust_proto && self.trusted(ip)
}
pub fn context(
&self,
incoming: &HeaderMap,
peer: SocketAddr,
local: SocketAddr,
tls: bool,
authority: &HeaderValue,
uri: &Uri,
) -> Result<Context> {
let peer_ip = canonical(peer.ip());
let trusted_peer = self.trusted(peer_ip);
let mut ips = if trusted_peer {
chain(incoming).unwrap_or_default()
} else {
Vec::new()
};
ips.push(peer_ip);
// Discard everything left of the first untrusted hop walking from us.
let first = ips.iter().rposition(|ip| !self.trusted(*ip)).unwrap_or(0);
ips.drain(..first);
let client_ip = ips[0];
let mut scheme = if tls { "https" } else { "http" };
if trusted_peer
&& self.trust_proto
&& incoming.get_all("x-forwarded-proto").iter().count() == 1
{
scheme = match incoming
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
{
Some("https") => "https",
Some("http") => "http",
_ => scheme,
};
}
let authority_text = authority.to_str()?;
// Validate before inserting authority into Forwarded's quoted host field.
crate::config::request_host(authority_text)?;
let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = format!("{scheme}://{authority_text}{original_target}");
let mut forwarded: Vec<_> = ips.iter().map(|ip| format!("for={}", node(*ip))).collect();
let last = forwarded.last_mut().expect("actual peer is always present");
last.push_str(&format!(
";by={};proto={scheme};host=\"{authority_text}\"",
node(canonical(local.ip()))
));
let mut values = HeaderMap::new();
values.insert("forwarded", forwarded.join(", ").parse()?);
values.insert(
"x-forwarded-for",
ips.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
.parse()?,
);
values.insert("x-real-ip", client_ip.to_string().parse()?);
values.insert("x-forwarded-host", authority.clone());
values.insert("x-forwarded-proto", HeaderValue::from_static(scheme));
let port = if trusted_peer && self.trust_proto {
authority_text
.parse::<http::uri::Authority>()?
.port_u16()
.unwrap_or(if scheme == "https" { 443 } else { 80 })
} else {
local.port()
};
values.insert("x-forwarded-port", port.to_string().parse()?);
values.insert("x-forwarded-uri", original_target.parse()?);
Ok(Context {
client_ip,
scheme,
original_url,
values,
})
}
}
impl Context {
/// Replace the effective client with a previously authenticated edge header.
/// Existing proxy-chain metadata is retained behind the visitor address.
pub fn override_client_ip(&mut self, client: IpAddr) -> Result<()> {
let client = canonical(client);
let client_text = client.to_string();
let existing_xff = self
.values
.get("x-forwarded-for")
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let already_first = existing_xff
.split(',')
.next()
.and_then(parse_node)
.is_some_and(|value| value == client);
if !already_first {
let xff = if existing_xff.is_empty() {
client_text.clone()
} else {
format!("{client_text}, {existing_xff}")
};
self.values.insert("x-forwarded-for", xff.parse()?);
let existing = self
.values
.get("forwarded")
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let forwarded = if existing.is_empty() {
format!("for={}", node(client))
} else {
format!("for={}, {existing}", node(client))
};
self.values.insert("forwarded", forwarded.parse()?);
}
self.values.insert("x-real-ip", client_text.parse()?);
self.client_ip = client;
Ok(())
}
pub fn apply(&self, headers: &mut HeaderMap) {
let remove: Vec<_> = headers
.keys()
.filter(|name| {
name.as_str().starts_with("x-forwarded-")
|| matches!(
name.as_str(),
"forwarded" | "x-real-ip" | "x-original-url" | "x-original-method"
)
})
.cloned()
.collect();
for name in remove {
headers.remove(name);
}
for (name, value) in &self.values {
headers.insert(name.clone(), value.clone());
}
}
/// Apply trusted forwarding metadata to an application request. When client-IP
/// forwarding is disabled, preserve scheme/host/port/URI metadata but withhold
/// the visitor address so frameworks such as ProxyFix see the RustProxy socket peer.
/// Authentication verifiers use `apply` directly and still receive the real client IP.
pub fn apply_upstream(&self, headers: &mut HeaderMap, forward_client_ip: bool) {
self.apply(headers);
if !forward_client_ip {
headers.remove("forwarded");
headers.remove("x-forwarded-for");
headers.remove("x-real-ip");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn context(settings: Settings, incoming: HeaderMap, peer: &str) -> Context {
settings
.compile()
.unwrap()
.context(
&incoming,
peer.parse().unwrap(),
"10.0.0.1:8443".parse().unwrap(),
true,
&"app.example:8443".parse().unwrap(),
&"/a?x=1&y=2".parse().unwrap(),
)
.unwrap()
}
#[test]
fn untrusted_headers_are_rebuilt_not_appended() {
let mut headers = HeaderMap::new();
for (key, value) in [
("forwarded", "for=1.1.1.1"),
("x-forwarded-for", "2.2.2.2"),
("x-real-ip", "3.3.3.3"),
("x-forwarded-host", "evil.example"),
("x-forwarded-user", "admin"),
("x-original-url", "http://evil/"),
] {
headers.insert(key, value.parse().unwrap());
}
let context = context(Settings::default(), headers.clone(), "192.0.2.5:32000");
context.apply(&mut headers);
assert_eq!(headers["x-forwarded-for"], "192.0.2.5");
assert_eq!(headers["x-real-ip"], "192.0.2.5");
assert_eq!(headers["x-forwarded-host"], "app.example:8443");
assert!(!headers.contains_key("x-forwarded-user"));
assert!(!headers.contains_key("x-original-url"));
assert_eq!(
headers["forwarded"],
"for=192.0.2.5;by=10.0.0.1;proto=https;host=\"app.example:8443\""
);
}
#[test]
fn upstream_can_withhold_client_ip_without_losing_origin_metadata() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "203.0.113.99".parse().unwrap());
headers.insert("x-real-ip", "203.0.113.99".parse().unwrap());
headers.insert("forwarded", "for=203.0.113.99".parse().unwrap());
let context = context(Settings::default(), HeaderMap::new(), "192.0.2.5:32000");
context.apply_upstream(&mut headers, false);
assert!(!headers.contains_key("x-forwarded-for"));
assert!(!headers.contains_key("x-real-ip"));
assert!(!headers.contains_key("forwarded"));
assert_eq!(headers["x-forwarded-host"], "app.example:8443");
assert_eq!(headers["x-forwarded-proto"], "https");
assert_eq!(headers["x-forwarded-port"], "8443");
assert_eq!(headers["x-forwarded-uri"], "/a?x=1&y=2");
}
#[test]
fn upstream_client_ip_forwarding_remains_default_behavior() {
let mut headers = HeaderMap::new();
let context = context(Settings::default(), HeaderMap::new(), "192.0.2.5:32000");
context.apply_upstream(&mut headers, true);
assert_eq!(headers["x-forwarded-for"], "192.0.2.5");
assert_eq!(headers["x-real-ip"], "192.0.2.5");
assert!(headers.contains_key("forwarded"));
}
#[test]
fn trust_chain_stops_at_first_untrusted_hop() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
"6.6.6.6, 198.51.100.7, 10.1.2.3".parse().unwrap(),
);
let c = context(
Settings {
trusted_proxies: vec!["10.0.0.0/8".into()],
..Default::default()
},
headers,
"10.2.3.4:99",
);
assert_eq!(
c.values["x-forwarded-for"],
"198.51.100.7, 10.1.2.3, 10.2.3.4"
);
assert_eq!(c.client_ip.to_string(), "198.51.100.7");
}
#[test]
fn forwarded_only_ipv6_and_duplicate_fields() {
let mut h = HeaderMap::new();
h.append(
"forwarded",
"for=\"[2001:db8::1]:1234\";proto=http".parse().unwrap(),
);
h.append("forwarded", "for=10.1.1.1".parse().unwrap());
let c = context(
Settings {
trusted_proxies: vec!["10.0.0.0/8".into()],
..Default::default()
},
h,
"10.2.2.2:99",
);
assert_eq!(
c.values["x-forwarded-for"],
"2001:db8::1, 10.1.1.1, 10.2.2.2"
);
assert!(c.values["forwarded"]
.to_str()
.unwrap()
.starts_with("for=\"[2001:db8::1]\""));
}
#[test]
fn invalid_and_oversized_chains_fall_back_to_peer() {
for raw in [
"unknown".to_owned(),
"_hidden".into(),
"1.2.3.4, garbage".into(),
vec!["1.2.3.4"; 33].join(","),
] {
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", raw.parse().unwrap());
let c = context(
Settings {
trusted_proxies: vec!["10.0.0.0/8".into()],
..Default::default()
},
h,
"10.2.2.2:99",
);
assert_eq!(c.client_ip.to_string(), "10.2.2.2");
}
}
#[test]
fn proto_trust_is_explicit_and_host_never_comes_from_forwarded() {
let mut h = HeaderMap::new();
h.insert("x-forwarded-proto", "http".parse().unwrap());
h.insert("x-forwarded-host", "evil".parse().unwrap());
let settings = Settings {
trusted_proxies: vec!["10.0.0.0/8".into()],
trust_forwarded_proto: true,
};
let c = context(settings, h, "10.0.0.2:22");
assert_eq!(c.original_url, "http://app.example:8443/a?x=1&y=2");
}
#[test]
fn refuses_trust_all_and_invalid_cidrs() {
for value in ["0.0.0.0/0", "::/0", "broken"] {
assert!(Settings {
trusted_proxies: vec![value.into()],
..Default::default()
}
.compile()
.is_err());
}
}
#[test]
fn cloudflare_header_requires_cloudflare_peer() {
let mut headers = HeaderMap::new();
headers.insert("cf-connecting-ip", "203.0.113.8".parse().unwrap());
assert_eq!(
cloudflare_client_ip(&headers, "104.16.1.2:443".parse().unwrap()),
Some("203.0.113.8".parse().unwrap())
);
assert!(cloudflare_client_ip(&headers, "192.0.2.2:443".parse().unwrap()).is_none());
}
#[test]
fn authenticated_client_override_prepends_chain_once() {
let mut c = context(Settings::default(), HeaderMap::new(), "104.16.1.2:443");
c.override_client_ip("203.0.113.8".parse().unwrap())
.unwrap();
assert_eq!(c.client_ip.to_string(), "203.0.113.8");
assert_eq!(c.values["x-forwarded-for"], "203.0.113.8, 104.16.1.2");
c.override_client_ip("203.0.113.8".parse().unwrap())
.unwrap();
assert_eq!(c.values["x-forwarded-for"], "203.0.113.8, 104.16.1.2");
}
}