use std::net::IpAddr;
use std::str::FromStr;
use crate::ip_gate::{canonical, parse_network_entry};
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,
}
}
#[must_use]
pub fn canonicalize_ip(value: &str) -> String {
IpAddr::from_str(strip_ip_brackets(value)).map_or_else(|_| value.to_owned(), canonical_ip_text)
}
#[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 {
trusted_proxies.iter().any(|proxy| {
parse_network_entry(proxy).map_or_else(
|| connecting == proxy,
|network| {
IpAddr::from_str(connecting)
.is_ok_and(|addr| network.contains(canonical(addr)) || network.contains(addr))
},
)
})
}
#[must_use]
fn candidate_addr(candidate: &str) -> Option<IpAddr> {
IpAddr::from_str(candidate).ok()
}
#[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 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(&canonical_connecting, 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,
)
}
#[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"])));
}
}