use std::net::IpAddr;
use std::str::FromStr;
use crate::ip_gate::IpNet;
pub const UNKNOWN_CLIENT_IDENTITY: &str = "unknown";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientIpSource {
Connecting,
ForwardedChain,
Unknown,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ChainWarnings {
pub chain_too_short: bool,
pub depth_overcounts: Option<usize>,
pub selected_entry_trusted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientIpExtraction {
pub client_ip: String,
pub source: ClientIpSource,
pub untrusted_forwarded_header: bool,
pub warnings: ChainWarnings,
}
#[must_use]
fn strip_ip_brackets(value: &str) -> &str {
value
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or(value)
}
#[must_use]
pub fn strip_forwarded_entry_port(value: &str) -> &str {
if let Some(rest) = value.strip_prefix('[') {
let Some(closing) = rest.find(']') else {
return value;
};
let remainder = &rest[closing + 1..];
let port_form = remainder.len() > 1
&& remainder.starts_with(':')
&& remainder[1..].chars().all(|c| c.is_ascii_digit());
if remainder.is_empty() || port_form {
return &rest[..closing];
}
return value;
}
match value.split_once(':') {
Some((host, port))
if !host.contains(':')
&& !port.is_empty()
&& port.chars().all(|c| c.is_ascii_digit()) =>
{
host
}
_ => value,
}
}
fn parse_ip_literal(value: &str) -> Option<(IpAddr, Option<&str>)> {
match value.split_once('%') {
Some((address, zone)) if !zone.is_empty() && address.contains(':') => {
let addr = IpAddr::from_str(address).ok()?;
addr.is_ipv6().then_some((addr, Some(zone)))
}
Some(_) => None,
None => IpAddr::from_str(value).ok().map(|addr| (addr, None)),
}
}
fn literal_text(addr: IpAddr, zone: Option<&str>) -> String {
zone.map_or_else(|| canonical_ip_text(addr), |zone| format!("{addr}%{zone}"))
}
#[must_use]
pub fn canonicalize_ip(value: &str) -> String {
parse_ip_literal(strip_ip_brackets(value))
.map_or_else(|| value.to_owned(), |(addr, zone)| literal_text(addr, zone))
}
#[must_use]
pub fn canonical_ip_text(addr: IpAddr) -> String {
match addr {
IpAddr::V6(v6) => v6
.to_ipv4_mapped()
.map_or_else(|| v6.to_string(), |mapped| mapped.to_string()),
IpAddr::V4(v4) => v4.to_string(),
}
}
#[must_use]
pub fn candidate_has_metachar(candidate: &str) -> bool {
candidate
.chars()
.any(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
}
#[must_use]
pub fn forwarded_header_ips(forwarded_for: &str) -> Vec<String> {
forwarded_for
.split(',')
.map(|entry| strip_forwarded_entry_port(entry.trim()).to_owned())
.collect()
}
#[must_use]
pub fn is_trusted_proxy(connecting: &str, trusted_proxies: &[String]) -> bool {
let Some((addr, _zone)) = parse_ip_literal(connecting) else {
return false;
};
trusted_proxies.iter().any(|proxy| {
if proxy.contains('/') {
IpNet::parse(proxy).is_some_and(|network| network.contains(addr))
} else {
connecting == proxy
}
})
}
#[must_use]
fn candidate_addr(candidate: &str) -> Option<(IpAddr, Option<&str>)> {
parse_ip_literal(candidate)
}
#[must_use]
fn right_side_unlisted_count(
ips: &[String],
proxy_depth: u32,
trusted_proxies: &[String],
) -> usize {
let depth = usize::try_from(proxy_depth).unwrap_or(ips.len());
let start = ips.len().saturating_sub(depth.saturating_sub(1));
ips[start..]
.iter()
.filter(|entry| !is_trusted_proxy(&canonicalize_ip(entry), trusted_proxies))
.count()
}
#[must_use]
fn resolve_chain_right_to_left(ips: &[String], trusted_proxies: &[String]) -> Option<String> {
ips.iter()
.rev()
.find(|entry| !is_trusted_proxy(&canonicalize_ip(entry), trusted_proxies))
.and_then(|entry| {
if candidate_has_metachar(entry) {
return None;
}
candidate_addr(entry).map(|_| canonicalize_ip(entry))
})
}
#[must_use]
pub fn resolve_client_ip_from_forwarded_chain(
canonical_connecting: &str,
forwarded_for: Option<&str>,
proxy_depth: u32,
trusted_proxies: &[String],
) -> ClientIpExtraction {
let mut warnings = ChainWarnings::default();
let Some(forwarded_for) = forwarded_for.filter(|value| !value.is_empty()) else {
return ClientIpExtraction {
client_ip: canonical_connecting.to_owned(),
source: ClientIpSource::Connecting,
untrusted_forwarded_header: false,
warnings,
};
};
let ips = forwarded_header_ips(forwarded_for);
let chain_length = ips.len();
let depth = usize::try_from(proxy_depth).unwrap_or(0);
if depth == 0 || chain_length < depth {
warnings.chain_too_short = true;
return ClientIpExtraction {
client_ip: canonical_connecting.to_owned(),
source: ClientIpSource::Connecting,
untrusted_forwarded_header: false,
warnings,
};
}
if !trusted_proxies.is_empty() {
let unlisted = right_side_unlisted_count(&ips, proxy_depth, trusted_proxies);
if unlisted > 0 {
warnings.depth_overcounts = Some(unlisted);
let Some(resolved) = resolve_chain_right_to_left(&ips, trusted_proxies) else {
return ClientIpExtraction {
client_ip: canonical_connecting.to_owned(),
source: ClientIpSource::Connecting,
untrusted_forwarded_header: false,
warnings,
};
};
return ClientIpExtraction {
client_ip: resolved,
source: ClientIpSource::ForwardedChain,
untrusted_forwarded_header: false,
warnings,
};
}
}
let candidate = ips[chain_length - depth].as_str();
if candidate_has_metachar(candidate) || candidate_addr(candidate).is_none() {
return ClientIpExtraction {
client_ip: canonical_connecting.to_owned(),
source: ClientIpSource::Connecting,
untrusted_forwarded_header: false,
warnings,
};
}
let client_ip = canonicalize_ip(candidate);
warnings.selected_entry_trusted = is_trusted_proxy(&client_ip, trusted_proxies);
ClientIpExtraction {
client_ip,
source: ClientIpSource::ForwardedChain,
untrusted_forwarded_header: false,
warnings,
}
}
#[must_use]
pub fn extract_client_ip(
client_host: Option<&str>,
forwarded_for: Option<&str>,
trusted_proxies: &[String],
proxy_depth: u32,
) -> ClientIpExtraction {
let Some(client_host) = client_host.filter(|host| !host.is_empty()) else {
if trusted_proxies.iter().any(|proxy| proxy == "unix") {
let mut extraction = resolve_client_ip_from_forwarded_chain(
UNKNOWN_CLIENT_IDENTITY,
forwarded_for,
proxy_depth,
trusted_proxies,
);
if extraction.source == ClientIpSource::Connecting {
UNKNOWN_CLIENT_IDENTITY.clone_into(&mut extraction.client_ip);
extraction.source = ClientIpSource::Unknown;
}
return extraction;
}
return ClientIpExtraction {
client_ip: UNKNOWN_CLIENT_IDENTITY.to_owned(),
source: ClientIpSource::Unknown,
untrusted_forwarded_header: false,
warnings: ChainWarnings::default(),
};
};
let canonical_connecting = canonicalize_ip(client_host);
if trusted_proxies.is_empty() {
return ClientIpExtraction {
client_ip: canonical_connecting,
source: ClientIpSource::Connecting,
untrusted_forwarded_header: forwarded_for.is_some_and(|value| !value.is_empty()),
warnings: ChainWarnings::default(),
};
}
if !is_trusted_proxy(client_host, trusted_proxies) {
return ClientIpExtraction {
client_ip: canonical_connecting,
source: ClientIpSource::Connecting,
untrusted_forwarded_header: forwarded_for.is_some_and(|value| !value.is_empty()),
warnings: ChainWarnings::default(),
};
}
resolve_client_ip_from_forwarded_chain(
&canonical_connecting,
forwarded_for,
proxy_depth,
trusted_proxies,
)
}
pub const UNRESOLVABLE_CLIENT_ADDRESS_STATUS: u16 = 403;
pub const UNRESOLVABLE_CLIENT_ADDRESS_MESSAGE: &str = "Client address could not be determined";
pub const UNRESOLVABLE_CLIENT_ADDRESS_CHECK_NAME: &str = "client_address_unresolved";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientResolution {
pub extraction: ClientIpExtraction,
pub ip: Option<IpAddr>,
pub reject: bool,
}
#[must_use]
pub fn resolve_client(
peer: Option<IpAddr>,
forwarded_for: Option<&str>,
trusted_proxies: &[String],
proxy_depth: u32,
fail_secure: bool,
) -> ClientResolution {
let peer_text = peer.map(|addr| addr.to_string());
let extraction = extract_client_ip(
peer_text.as_deref(),
forwarded_for,
trusted_proxies,
proxy_depth,
);
let unknown = extraction.client_ip == UNKNOWN_CLIENT_IDENTITY;
let ip = if unknown {
None
} else {
parse_ip_literal(&extraction.client_ip)
.and_then(|(addr, zone)| zone.is_none().then_some(addr))
};
ClientResolution {
reject: peer.is_none() && unknown && fail_secure,
extraction,
ip,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn proxies(entries: &[&str]) -> Vec<String> {
entries.iter().map(ToString::to_string).collect()
}
#[test]
fn the_depth_selects_the_entry_from_the_right() {
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("203.0.113.7"),
&proxies(&["10.0.0.0/8"]),
1,
);
assert_eq!(extraction.client_ip, "203.0.113.7");
assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
assert!(!extraction.warnings.selected_entry_trusted);
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("203.0.113.7, 198.51.100.4, 10.0.0.1"),
&proxies(&["10.0.0.0/8", "198.51.100.0/24"]),
3,
);
assert_eq!(extraction.client_ip, "203.0.113.7");
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("203.0.113.7, 10.0.0.1"),
&proxies(&["10.0.0.0/8"]),
1,
);
assert_eq!(extraction.client_ip, "10.0.0.1");
assert!(extraction.warnings.selected_entry_trusted);
}
#[test]
fn a_short_chain_falls_back_to_the_connecting_peer() {
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("203.0.113.7"),
&proxies(&["10.0.0.0/8"]),
3,
);
assert_eq!(extraction.client_ip, "10.0.0.1");
assert_eq!(extraction.source, ClientIpSource::Connecting);
assert!(extraction.warnings.chain_too_short);
}
#[test]
fn an_over_counting_depth_walks_right_to_left() {
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("203.0.113.7, 198.51.100.4, 10.0.0.1"),
&proxies(&["10.0.0.0/8"]),
3,
);
assert_eq!(extraction.client_ip, "198.51.100.4");
assert_eq!(extraction.warnings.depth_overcounts, Some(1));
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("10.0.0.2, 10.0.0.1"),
&proxies(&["10.0.0.0/8"]),
1,
);
assert_eq!(extraction.client_ip, "10.0.0.1");
assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
assert_eq!(extraction.warnings.depth_overcounts, None);
}
#[test]
fn unparseable_and_metachar_candidates_degrade_to_connecting() {
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("not-an-ip"),
&proxies(&["10.0.0.0/8"]),
1,
);
assert_eq!(extraction.client_ip, "10.0.0.1");
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("*.evil.example"),
&proxies(&["10.0.0.0/8"]),
1,
);
assert_eq!(extraction.client_ip, "10.0.0.1");
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("203.0.113.7, *.evil.example, 10.0.0.1"),
&proxies(&["10.0.0.0/8"]),
3,
);
assert_eq!(extraction.client_ip, "10.0.0.1");
assert_eq!(extraction.warnings.depth_overcounts, Some(1));
}
#[test]
fn a_selected_trusted_entry_warns() {
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("10.0.0.9, 10.0.0.1"),
&proxies(&["10.0.0.0/8"]),
2,
);
assert_eq!(extraction.client_ip, "10.0.0.9");
assert!(extraction.warnings.selected_entry_trusted);
}
#[test]
fn untrusted_peers_and_absent_proxies_ignore_the_header() {
let extraction = extract_client_ip(
Some("192.0.2.9"),
Some("203.0.113.7"),
&proxies(&["10.0.0.0/8"]),
1,
);
assert_eq!(extraction.client_ip, "192.0.2.9");
assert_eq!(extraction.source, ClientIpSource::Connecting);
assert!(extraction.untrusted_forwarded_header);
let extraction = extract_client_ip(Some("192.0.2.9"), Some("203.0.113.7"), &[], 1);
assert_eq!(extraction.client_ip, "192.0.2.9");
assert!(extraction.untrusted_forwarded_header);
let extraction = extract_client_ip(Some("192.0.2.9"), None, &proxies(&["10.0.0.0/8"]), 1);
assert!(!extraction.untrusted_forwarded_header);
}
#[test]
fn canonicalization_strips_brackets_ports_and_v4_mapping() {
assert_eq!(
strip_forwarded_entry_port("[2001:db8::1]:443"),
"2001:db8::1"
);
assert_eq!(strip_forwarded_entry_port("2001:db8::1"), "2001:db8::1");
assert_eq!(
strip_forwarded_entry_port("203.0.113.7:8443"),
"203.0.113.7"
);
assert_eq!(strip_forwarded_entry_port("203.0.113.7"), "203.0.113.7");
assert_eq!(strip_forwarded_entry_port("2001:db8::1"), "2001:db8::1");
assert_eq!(canonicalize_ip("[2001:db8::1]"), "2001:db8::1");
assert_eq!(canonicalize_ip("::ffff:203.0.113.7"), "203.0.113.7");
assert_eq!(canonicalize_ip("not-an-ip"), "not-an-ip");
}
#[test]
fn the_unknown_arm_walks_only_for_unix_proxies() {
let extraction = extract_client_ip(None, Some("203.0.113.7"), &proxies(&["unix"]), 1);
assert_eq!(extraction.client_ip, "203.0.113.7");
assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
let extraction = extract_client_ip(None, Some("203.0.113.7"), &[], 1);
assert_eq!(extraction.client_ip, UNKNOWN_CLIENT_IDENTITY);
assert_eq!(extraction.source, ClientIpSource::Unknown);
let extraction = extract_client_ip(None, None, &proxies(&["unix"]), 1);
assert_eq!(extraction.client_ip, UNKNOWN_CLIENT_IDENTITY);
assert_eq!(extraction.source, ClientIpSource::Unknown);
}
#[test]
fn malformed_bracket_entries_pass_through_untouched() {
assert_eq!(strip_forwarded_entry_port("[2001:db8::1"), "[2001:db8::1");
assert_eq!(
strip_forwarded_entry_port("[2001:db8::1]junk"),
"[2001:db8::1]junk"
);
assert_eq!(strip_forwarded_entry_port("[2001:db8::1]"), "2001:db8::1");
assert_eq!(
strip_forwarded_entry_port("[2001:db8::1]:443"),
"2001:db8::1"
);
}
#[test]
fn an_empty_trusted_list_still_walks_at_the_declared_depth() {
let extraction =
resolve_client_ip_from_forwarded_chain("10.0.0.1", Some("203.0.113.7"), 1, &[]);
assert_eq!(extraction.client_ip, "203.0.113.7");
assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
assert_eq!(extraction.warnings, ChainWarnings::default());
}
#[test]
fn an_unparseable_rightmost_untrusted_entry_ends_the_walk() {
let extraction = extract_client_ip(
Some("10.0.0.1"),
Some("203.0.113.7, not-an-ip, 10.0.0.1"),
&proxies(&["10.0.0.0/8"]),
3,
);
assert_eq!(extraction.client_ip, "10.0.0.1");
assert_eq!(extraction.warnings.depth_overcounts, Some(1));
}
#[test]
fn the_unix_token_never_matches_a_real_peer() {
assert!(!is_trusted_proxy("127.0.0.1", &proxies(&["unix"])));
assert!(is_trusted_proxy(
"10.1.2.3",
&proxies(&["unix", "10.0.0.0/8"])
));
assert!(is_trusted_proxy("10.1.2.3", &proxies(&["10.1.2.3"])));
}
#[allow(clippy::type_complexity)]
const REFERENCE: &[(Option<&str>, Option<&str>, &[&str], u32, &str)] = &[
(
Some("::ffff:10.0.0.1"),
Some("203.0.113.7"),
&["10.0.0.0/8"],
1,
"10.0.0.1",
),
(
Some("::ffff:10.0.0.1"),
Some("203.0.113.7"),
&["10.0.0.1"],
1,
"10.0.0.1",
),
(
Some("10.0.0.1"),
Some("203.0.113.7"),
&["10.0.0.0/8"],
1,
"203.0.113.7",
),
(
Some("::FFFF:10.0.0.1"),
Some("203.0.113.7"),
&[],
1,
"10.0.0.1",
),
(Some("FE80::1%eth0"), None, &[], 1, "fe80::1%eth0"),
(Some("fe80::1%eth0"), None, &[], 1, "fe80::1%eth0"),
(Some("2001:DB8::1"), None, &[], 1, "2001:db8::1"),
(
Some("10.0.0.1"),
Some("[2001:db8::1]:443"),
&["10.0.0.1"],
1,
"2001:db8::1",
),
(None, Some("203.0.113.7"), &["unix"], 1, "203.0.113.7"),
(None, Some("203.0.113.7"), &[], 1, "unknown"),
(None, None, &["unix"], 1, "unknown"),
(Some(""), Some("203.0.113.7"), &["unix"], 1, "203.0.113.7"),
(Some("not-an-ip"), None, &[], 1, "not-an-ip"),
(
Some("10.0.0.1"),
Some("1.1.1.1, 203.0.113.7"),
&["10.0.0.1"],
2,
"203.0.113.7",
),
(
Some("10.0.0.1"),
Some("203.0.113.7"),
&["10.0.0.1"],
3,
"10.0.0.1",
),
(Some("::1"), Some("203.0.113.7"), &["::1"], 1, "203.0.113.7"),
(
Some("0:0:0:0:0:ffff:a00:1"),
Some("9.9.9.9"),
&["10.0.0.1"],
1,
"10.0.0.1",
),
];
#[test]
fn identities_follow_the_reference_table() {
for (index, (peer, forwarded, proxy_list, depth, expected)) in REFERENCE.iter().enumerate()
{
let extraction = extract_client_ip(*peer, *forwarded, &proxies(proxy_list), *depth);
assert_eq!(extraction.client_ip, *expected, "reference row {index}");
}
}
#[test]
fn a_peerless_request_is_rejected_only_when_unresolved_and_fail_secure() {
let rejected = resolve_client(None, Some("203.0.113.7"), &[], 1, true);
assert!(rejected.reject);
assert_eq!(rejected.ip, None);
assert_eq!(rejected.extraction.source, ClientIpSource::Unknown);
let open = resolve_client(None, Some("203.0.113.7"), &[], 1, false);
assert!(!open.reject);
assert_eq!(open.ip, None);
let unix = resolve_client(None, Some("203.0.113.7"), &proxies(&["unix"]), 1, true);
assert!(!unix.reject);
assert_eq!(unix.ip, Some("203.0.113.7".parse().unwrap()));
}
#[test]
fn a_peer_resolves_through_the_trusted_chain_and_never_rejects() {
let resolution = resolve_client(
Some("10.0.0.1".parse().unwrap()),
Some("203.0.113.7"),
&proxies(&["10.0.0.0/8"]),
1,
true,
);
assert!(!resolution.reject);
assert_eq!(resolution.ip, Some("203.0.113.7".parse().unwrap()));
let untrusted = resolve_client(
Some("192.0.2.9".parse().unwrap()),
Some("203.0.113.7"),
&proxies(&["10.0.0.0/8"]),
1,
true,
);
assert_eq!(untrusted.ip, Some("192.0.2.9".parse().unwrap()));
assert!(untrusted.extraction.untrusted_forwarded_header);
let mapped = resolve_client(
Some("::ffff:192.0.2.9".parse().unwrap()),
None,
&[],
1,
true,
);
assert_eq!(mapped.ip, Some("192.0.2.9".parse().unwrap()));
}
#[test]
fn a_zoned_chain_entry_keeps_its_text_but_has_no_address() {
let resolution = resolve_client(None, Some("fe80::1%eth0"), &proxies(&["unix"]), 1, true);
assert_eq!(resolution.extraction.client_ip, "fe80::1%eth0");
assert_eq!(resolution.ip, None);
assert!(!resolution.reject);
}
#[test]
fn a_malformed_zone_literal_is_not_an_address() {
for raw in ["1.2.3.4%eth0", "fe80::1%", "not:an:ip%eth0"] {
assert_eq!(canonicalize_ip(raw), raw);
assert!(!is_trusted_proxy(raw, &proxies(&["fe80::/10", raw])));
}
assert!(is_trusted_proxy("fe80::1%eth0", &proxies(&["fe80::/10"])));
}
}