v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user