use std::net::{IpAddr, SocketAddr};
use axum::http::HeaderMap;
use ipnet::IpNet;
pub(crate) const MAX_USER_AGENT: usize = 512;
pub(crate) fn parse_trusted(ranges: &[String]) -> Vec<IpNet> {
ranges
.iter()
.filter_map(|raw| match raw.parse::<IpNet>() {
Ok(net) => Some(net),
Err(e) => {
tracing::error!(range = %raw, error = %e, "ignoring an unparseable trusted proxy range");
None
}
})
.collect()
}
fn trusted(ip: IpAddr, trusted_proxies: &[IpNet]) -> bool {
trusted_proxies.iter().any(|net| net.contains(&ip))
}
pub(crate) fn client_ip(
peer: Option<SocketAddr>,
headers: &HeaderMap,
trusted_proxies: &[IpNet],
) -> Option<String> {
let peer_ip = peer.map(|addr| addr.ip())?;
if trusted_proxies.is_empty() || !trusted(peer_ip, trusted_proxies) {
return Some(peer_ip.to_string());
}
let forwarded = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
forwarded
.split(',')
.map(str::trim)
.filter_map(|s| s.parse::<IpAddr>().ok())
.rev()
.find(|ip| !trusted(*ip, trusted_proxies))
.map(|ip| ip.to_string())
.or_else(|| Some(peer_ip.to_string()))
}
pub(crate) fn user_agent(headers: &HeaderMap) -> Option<String> {
let raw = headers.get("user-agent")?.to_str().ok()?;
if raw.is_empty() {
return None;
}
let mut capped = raw.to_string();
if capped.len() > MAX_USER_AGENT {
let end = (0..=MAX_USER_AGENT)
.rev()
.find(|i| capped.is_char_boundary(*i))
.unwrap_or(0);
capped.truncate(end);
}
Some(capped)
}
pub(crate) fn describe(user_agent: Option<&str>) -> Option<String> {
let ua = user_agent?;
const BROWSERS: [(&str, &str); 8] = [
("Edg/", "Edge"),
("OPR/", "Opera"),
("Vivaldi", "Vivaldi"),
("Brave", "Brave"),
("Firefox/", "Firefox"),
("Chrome/", "Chrome"),
("Safari/", "Safari"),
("curl/", "curl"),
];
const SYSTEMS: [(&str, &str); 7] = [
("iPhone", "iPhone"),
("iPad", "iPad"),
("Android", "Android"),
("Mac OS X", "macOS"),
("Windows NT", "Windows"),
("CrOS", "ChromeOS"),
("Linux", "Linux"),
];
let browser = BROWSERS
.iter()
.find(|(token, _)| ua.contains(token))
.map(|(_, name)| *name);
let system = SYSTEMS
.iter()
.find(|(token, _)| ua.contains(token))
.map(|(_, name)| *name);
match (browser, system) {
(Some(b), Some(s)) => Some(format!("{b} on {s}")),
(Some(b), None) => Some(b.to_string()),
(None, Some(s)) => Some(s.to_string()),
(None, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use std::net::SocketAddr;
fn nets(ranges: &[&str]) -> Vec<IpNet> {
parse_trusted(&ranges.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
fn headers(xff: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(v) = xff {
h.insert("x-forwarded-for", HeaderValue::from_str(v).unwrap());
}
h
}
fn peer(addr: &str) -> Option<SocketAddr> {
Some(addr.parse().unwrap())
}
#[test]
fn with_no_trusted_proxies_the_header_is_ignored() {
let got = client_ip(peer("203.0.113.9:4000"), &headers(Some("1.2.3.4")), &[]);
assert_eq!(got.as_deref(), Some("203.0.113.9"));
}
#[test]
fn a_peer_outside_the_trusted_range_is_believed_over_its_header() {
let got = client_ip(
peer("203.0.113.9:4000"),
&headers(Some("1.2.3.4")),
&nets(&["10.0.0.0/8"]),
);
assert_eq!(got.as_deref(), Some("203.0.113.9"));
}
#[test]
fn a_trusted_proxy_hands_over_the_client() {
let got = client_ip(
peer("10.0.0.7:4000"),
&headers(Some("198.51.100.4")),
&nets(&["10.0.0.0/8"]),
);
assert_eq!(got.as_deref(), Some("198.51.100.4"));
}
#[test]
fn the_rightmost_untrusted_hop_wins() {
let got = client_ip(
peer("10.0.0.7:4000"),
&headers(Some("1.1.1.1, 198.51.100.4, 10.0.0.3")),
&nets(&["10.0.0.0/8"]),
);
assert_eq!(got.as_deref(), Some("198.51.100.4"));
}
#[test]
fn a_chain_of_only_our_own_hops_falls_back_to_the_peer() {
let got = client_ip(
peer("10.0.0.7:4000"),
&headers(Some("10.0.0.3")),
&nets(&["10.0.0.0/8"]),
);
assert_eq!(got.as_deref(), Some("10.0.0.7"));
}
#[test]
fn an_unparseable_range_is_dropped_not_fatal() {
let parsed = nets(&["10.0.0.0/8", "not-a-range"]);
assert_eq!(parsed.len(), 1);
}
#[test]
fn a_long_user_agent_is_capped() {
let mut h = HeaderMap::new();
h.insert(
"user-agent",
HeaderValue::from_str(&"A".repeat(4000)).unwrap(),
);
let got = user_agent(&h).unwrap();
assert_eq!(got.len(), MAX_USER_AGENT);
}
#[test]
fn a_non_ascii_user_agent_is_declined_rather_than_mangled() {
let mut h = HeaderMap::new();
h.insert(
"user-agent",
HeaderValue::from_bytes("é".repeat(400).as_bytes()).unwrap(),
);
assert_eq!(user_agent(&h), None);
}
#[test]
fn browsers_are_named_past_their_compatibility_tokens() {
let edge = "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0";
assert_eq!(describe(Some(edge)).as_deref(), Some("Edge on Windows"));
let chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
assert_eq!(describe(Some(chrome)).as_deref(), Some("Chrome on macOS"));
}
#[test]
fn an_unrecognised_agent_is_not_guessed_at() {
assert_eq!(describe(Some("something-bespoke/1.0")), None);
assert_eq!(describe(None), None);
}
}