485 lines
18 KiB
Rust
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");
|