Files
RustProxy/src/forwarding.rs
T
2026-10-18 09:39:49 +02:00

485 lines
18 KiB
Rust

//! 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,
}
include!("forwarding/peer_formatting.rs");
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()
});
fn cloudflare_peer(peer: SocketAddr) -> bool {
let peer = canonical(peer.ip());
CLOUDFLARE_NETWORKS
.iter()
.any(|network| network.contains(&peer))
}
/// 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> {
if !cloudflare_peer(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)
}
/// Return a trusted ISO country code supplied by Cloudflare. `CF-IPCountry` is
/// accepted only from a Cloudflare edge and only as one two-letter code.
pub fn cloudflare_country(headers: &HeaderMap, peer: SocketAddr) -> Option<String> {
if !cloudflare_peer(peer) {
return None;
}
let mut values = headers.get_all("cf-ipcountry").iter();
let value = values.next()?;
if values.next().is_some() {
return None;
}
let value = value.to_str().ok()?.trim();
if value.len() != 2 || !value.bytes().all(|byte| byte.is_ascii_alphabetic()) {
return None;
}
let country = value.to_ascii_uppercase();
(country != "XX").then_some(country)
}
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> {
self.context_with_original_url(incoming, peer, local, tls, authority, uri, true)
}
/// The absolute original URL is only consumed by the optional Tinyauth
/// verifier. Ordinary proxy requests need the exact forwarding metadata,
/// but do not need to allocate this extra diagnostic URL.
pub fn context_with_original_url(
&self,
incoming: &HeaderMap,
peer: SocketAddr,
local: SocketAddr,
tls: bool,
authority: &HeaderValue,
uri: &Uri,
include_original_url: bool,
) -> Result<Context> {
self.context_with_peer(
incoming, peer, local, tls, authority, uri, include_original_url,
None, false,
)
}
/// Only the host/path validation and formatting data from the same request
/// and accepted connection are reused. Dynamic trust and XFF are evaluated
/// on every request, including after an in-place configuration reload.
#[allow(clippy::too_many_arguments)]
pub(crate) fn context_with_peer(
&self,
incoming: &HeaderMap,
peer: SocketAddr,
local: SocketAddr,
tls: bool,
authority: &HeaderValue,
uri: &Uri,
include_original_url: bool,
cached: Option<&PeerFormatting>,
validated_host: bool,
) -> Result<Context> {
let cached = cached.filter(|cached| cached.peer == peer && cached.local == local);
let peer_ip = cached.map_or_else(|| canonical(peer.ip()), |cached| cached.peer_ip);
let trusted_peer = self.trusted(peer_ip);
if !trusted_peer {
let scheme = if tls { "https" } else { "http" };
let authority_text = authority.to_str()?;
if !validated_host { crate::config::request_host(authority_text)?; }
let target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = if include_original_url {
format!("{scheme}://{authority_text}{target}")
} else {
String::new()
};
// Repeated HTTP/1.1 requests generally share Host and TLS state.
// Cache only the untrusted-peer metadata for this accepted socket;
// X-Forwarded-Uri, original URL and all trust decisions remain per-request.
let target_header: HeaderValue = target.parse()?;
let mut values = if let Some(values) =
cached.and_then(|peer| peer.cached_untrusted(authority, tls))
{
values
} else {
let peer_text = cached.is_none().then(|| peer_ip.to_string());
let ipv6_node = (cached.is_none() && peer_ip.is_ipv6()).then(|| node(peer_ip));
let peer_header = if let Some(cached) = cached {
cached.peer_header.clone()
} else {
HeaderValue::from_str(peer_text.as_deref().unwrap_or(""))?
};
let by_text = cached.is_none().then(|| node(canonical(local.ip())));
let peer_node = cached.map_or_else(
|| if peer_ip.is_ipv4() { peer_text.as_deref().unwrap_or("") }
else { ipv6_node.as_deref().unwrap_or("") },
|cached| cached.peer_node.as_str(),
);
let by_node = cached.map_or_else(|| by_text.as_deref().unwrap_or(""),
|cached| cached.by_node.as_str());
let forwarded = format!(
"for={peer_node};by={by_node};proto={scheme};host=\"{authority_text}\""
);
let mut values = HeaderMap::with_capacity(7);
values.insert("forwarded", forwarded.parse()?);
values.insert("x-forwarded-for", peer_header.clone());
values.insert("x-real-ip", peer_header);
values.insert("x-forwarded-host", authority.clone());
values.insert("x-forwarded-proto", HeaderValue::from_static(scheme));
values.insert("x-forwarded-port", if let Some(cached) = cached {
cached.port_header.clone()
} else {
local.port().to_string().parse()?
});
if let Some(peer) = cached {
peer.remember_untrusted(authority, tls, &values);
}
values
};
values.insert("x-forwarded-uri", target_header);
return Ok(Context {
client_ip: peer_ip,
scheme,
original_url,
values,
});
}
let mut ips = chain(incoming).unwrap_or_default();
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.
if !validated_host { crate::config::request_host(authority_text)?; }
let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = if include_original_url {
format!("{scheme}://{authority_text}{original_target}")
} else {
String::new()
};
use std::fmt::Write as _;
let mut forwarded = String::with_capacity(ips.len() * 52 + 80 + authority_text.len());
let mut xff = String::with_capacity(ips.len() * 44);
for (index, ip) in ips.iter().enumerate() {
if index > 0 { forwarded.push_str(", "); xff.push_str(", "); }
forwarded.push_str("for=");
if index + 1 == ips.len() {
if let Some(cached) = cached { forwarded.push_str(&cached.peer_node); }
else { forwarded.push_str(&node(*ip)); }
} else {
forwarded.push_str(&node(*ip));
}
if index + 1 == ips.len() {
if let Some(cached) = cached { xff.push_str(cached.peer_header.to_str()?); }
else { write!(&mut xff, "{ip}")?; }
} else {
write!(&mut xff, "{ip}")?;
}
}
forwarded.push_str(";by=");
if let Some(cached) = cached { forwarded.push_str(&cached.by_node); }
else { forwarded.push_str(&node(canonical(local.ip()))); }
write!(&mut forwarded, ";proto={scheme};host=\"{authority_text}\"")?;
let mut values = HeaderMap::with_capacity(7);
values.insert("forwarded", forwarded.parse()?);
values.insert("x-forwarded-for", xff.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", if cached.is_some() && !(trusted_peer && self.trust_proto) {
cached.expect("tested above").port_header.clone()
} else {
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) {
self.apply_clean(headers, true);
}
fn apply_clean(&self, headers: &mut HeaderMap, include_client_ip: bool) {
// HeaderMap::insert replaces all values for a known canonical name.
// Remove unknown/spoofed forwarding names while retaining the valid ones.
// The normal request has no extra spoofed header names. Allocate
// a removal list only for requests which actually need sanitization.
let mut remove = None;
for name in headers.keys() {
let text = name.as_str();
if matches!(text, "x-original-url" | "x-original-method")
|| (text.starts_with("x-forwarded-")
&& !self.values.contains_key(text))
{
remove.get_or_insert_with(Vec::new).push(name.clone());
}
}
if let Some(remove) = remove {
for name in remove { headers.remove(name); }
}
if !include_client_ip {
for name in ["forwarded", "x-forwarded-for", "x-real-ip"] {
headers.remove(name);
}
}
for (name, value) in &self.values {
if !include_client_ip && matches!(name.as_str(), "forwarded" | "x-forwarded-for" | "x-real-ip") {
continue;
}
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_clean(headers, forward_client_ip);
}
}
#[cfg(test)]
include!("forwarding/tests.rs");