use std::net::IpAddr;
use ipnet::IpNet;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ClientContext {
pub ip: IpAddr,
pub user_agent: Option<String>,
pub ch_platform: Option<String>,
pub ch_mobile: Option<bool>,
pub accept_language: Option<String>,
pub tls_fingerprint: Option<String>,
}
impl ClientContext {
pub fn new(ip: IpAddr) -> Self {
Self {
ip: canonical_ip(ip),
user_agent: None,
ch_platform: None,
ch_mobile: None,
accept_language: None,
tls_fingerprint: None,
}
}
#[must_use]
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
#[must_use]
pub fn with_ch_platform(mut self, platform: impl AsRef<str>) -> Self {
self.ch_platform = Some(platform.as_ref().trim().trim_matches('"').to_owned());
self
}
#[must_use]
pub fn with_ch_mobile(mut self, mobile: bool) -> Self {
self.ch_mobile = Some(mobile);
self
}
#[must_use]
pub fn with_accept_language(mut self, value: impl Into<String>) -> Self {
self.accept_language = Some(value.into());
self
}
#[must_use]
pub fn with_tls_fingerprint(mut self, value: impl Into<String>) -> Self {
self.tls_fingerprint = Some(value.into());
self
}
pub(crate) fn from_headers<H: HeaderSource + ?Sized>(
ip: IpAddr,
headers: &H,
tls_header: Option<&str>,
max_len: usize,
) -> Self {
let get = |name: &str| first_header(headers, name, max_len);
Self {
ip: canonical_ip(ip),
user_agent: get("user-agent"),
ch_platform: get("sec-ch-ua-platform")
.map(|p| p.trim_matches('"').trim().to_owned())
.filter(|p| !p.is_empty()),
ch_mobile: get("sec-ch-ua-mobile").and_then(|m| match m.as_str() {
"?1" => Some(true),
"?0" => Some(false),
_ => None,
}),
accept_language: get("accept-language"),
tls_fingerprint: tls_header.and_then(get),
}
}
}
pub trait HeaderSource {
fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a;
}
impl HeaderSource for [(&str, &str)] {
fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a {
self.iter()
.filter(move |(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_bytes())
}
}
impl<const N: usize> HeaderSource for [(&str, &str); N] {
fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a {
self.as_slice().header_values(name)
}
}
impl HeaderSource for Vec<(&str, &str)> {
fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a {
self.as_slice().header_values(name)
}
}
#[cfg(feature = "axum")]
impl HeaderSource for http::HeaderMap {
fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a {
self.get_all(name).iter().map(http::HeaderValue::as_bytes)
}
}
pub(crate) fn first_header<H: HeaderSource + ?Sized>(headers: &H, name: &str, max_len: usize) -> Option<String> {
let raw = headers.header_values(name).next()?;
let value = std::str::from_utf8(raw).ok()?.trim();
if value.is_empty() {
return None;
}
Some(truncate(value, max_len).to_owned())
}
pub(crate) fn truncate(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub fn canonical_ip(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
v4 @ IpAddr::V4(_) => v4,
}
}
pub fn resolve_client_ip<'a>(peer: IpAddr, xff_values: impl IntoIterator<Item = &'a str>, trusted: &[IpNet]) -> IpAddr {
let peer = canonical_ip(peer);
let is_trusted = |ip: &IpAddr| trusted.iter().any(|net| net.contains(ip));
if !is_trusted(&peer) {
return peer;
}
let values: Vec<&str> = xff_values.into_iter().collect();
let mut client = peer;
for hop in values.iter().rev().flat_map(|v| v.rsplit(',')) {
let Some(ip) = parse_hop(hop) else {
return peer;
};
client = ip;
if !is_trusted(&ip) {
break;
}
}
client
}
fn parse_hop(hop: &str) -> Option<IpAddr> {
let hop = hop.trim();
if let Ok(ip) = hop.parse::<IpAddr>() {
return Some(canonical_ip(ip));
}
if let Ok(sock) = hop.parse::<std::net::SocketAddr>() {
return Some(canonical_ip(sock.ip()));
}
None
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
fn nets(s: &[&str]) -> Vec<IpNet> {
s.iter().map(|n| n.parse().unwrap()).collect()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn untrusted_peer_ignores_xff() {
assert_eq!(resolve_client_ip(ip("1.2.3.4"), ["9.9.9.9"], &[]), ip("1.2.3.4"));
assert_eq!(
resolve_client_ip(ip("1.2.3.4"), ["9.9.9.9"], &nets(&["10.0.0.0/8"])),
ip("1.2.3.4")
);
}
#[test]
fn walks_right_to_left_past_trusted_hops() {
let trusted = nets(&["10.0.0.0/8"]);
assert_eq!(
resolve_client_ip(ip("10.0.0.1"), ["6.6.6.6, 5.5.5.5, 10.0.0.2"], &trusted),
ip("5.5.5.5")
);
assert_eq!(
resolve_client_ip(ip("10.0.0.1"), ["6.6.6.6", "5.5.5.5, 10.0.0.2"], &trusted),
ip("5.5.5.5")
);
}
#[test]
fn spoofed_leftmost_entry_is_ignored() {
let trusted = nets(&["10.0.0.0/8"]);
assert_eq!(
resolve_client_ip(ip("10.0.0.1"), ["1.1.1.1, 7.7.7.7"], &trusted),
ip("7.7.7.7")
);
}
#[test]
fn garbage_falls_back_to_peer() {
let trusted = nets(&["10.0.0.0/8"]);
assert_eq!(
resolve_client_ip(ip("10.0.0.1"), ["7.7.7.7, nope"], &trusted),
ip("10.0.0.1")
);
assert_eq!(resolve_client_ip(ip("10.0.0.1"), [""], &trusted), ip("10.0.0.1"));
assert_eq!(
resolve_client_ip(ip("10.0.0.1"), ["7.7.7.7,"], &trusted),
ip("10.0.0.1")
);
}
#[test]
fn all_trusted_chain_returns_leftmost() {
let trusted = nets(&["10.0.0.0/8"]);
assert_eq!(
resolve_client_ip(ip("10.0.0.1"), ["10.0.0.3, 10.0.0.2"], &trusted),
ip("10.0.0.3")
);
assert_eq!(
resolve_client_ip(ip("10.0.0.1"), Vec::<&str>::new(), &trusted),
ip("10.0.0.1")
);
}
#[test]
fn ports_brackets_and_mapped_addresses() {
let trusted = nets(&["10.0.0.0/8", "::1/128"]);
assert_eq!(
resolve_client_ip(ip("::1"), ["[2001:db8::1]:443"], &trusted),
ip("2001:db8::1")
);
assert_eq!(
resolve_client_ip(ip("::ffff:10.0.0.1"), ["8.8.8.8:80"], &trusted),
ip("8.8.8.8")
);
assert_eq!(
resolve_client_ip(ip("::ffff:1.2.3.4"), ["8.8.8.8"], &trusted),
ip("1.2.3.4")
);
}
#[test]
fn reads_and_normalises_headers() {
let headers = [
("User-Agent", " Mozilla/5.0 "),
("sec-ch-ua-platform", "\"macOS\""),
("Sec-CH-UA-Mobile", "?0"),
("accept-language", "en-US,en;q=0.9"),
("x-ja4", "t13d1516h2_8daaf6152771_02713d6af862"),
];
let ctx = ClientContext::from_headers(ip("1.2.3.4"), &headers, Some("x-ja4"), 512);
assert_eq!(ctx.user_agent.as_deref(), Some("Mozilla/5.0"));
assert_eq!(ctx.ch_platform.as_deref(), Some("macOS"));
assert_eq!(ctx.ch_mobile, Some(false));
assert_eq!(ctx.accept_language.as_deref(), Some("en-US,en;q=0.9"));
assert!(ctx.tls_fingerprint.is_some());
let ctx = ClientContext::from_headers(
ip("1.2.3.4"),
&[("sec-ch-ua-mobile", "maybe"), ("user-agent", "")],
None,
512,
);
assert_eq!(ctx.ch_mobile, None);
assert_eq!(ctx.user_agent, None);
assert_eq!(ctx.tls_fingerprint, None);
}
#[test]
fn truncates_at_char_boundary() {
let long = "é".repeat(400);
let headers = [("user-agent", long.as_str())];
let ctx = ClientContext::from_headers(ip("1.2.3.4"), &headers, None, 513);
assert_eq!(ctx.user_agent.unwrap().len(), 512);
}
proptest! {
#[test]
fn resolve_never_panics(xff in proptest::collection::vec(".{0,60}", 0..4), v6 in any::<bool>()) {
let trusted = nets(&["0.0.0.0/0", "::/0"]);
let peer = if v6 { ip("::1") } else { ip("127.0.0.1") };
let _ = resolve_client_ip(peer, xff.iter().map(String::as_str), &trusted);
}
#[test]
fn untrusted_peer_always_wins(xff in ".{0,60}", a in any::<u32>()) {
let peer = IpAddr::V4(a.into());
prop_assert_eq!(resolve_client_ip(peer, [xff.as_str()], &[]), peer);
}
}
}