1use std::net::IpAddr;
4
5use ipnet::IpNet;
6
7#[derive(Clone, Debug, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct ClientContext {
13 pub ip: IpAddr,
15 pub user_agent: Option<String>,
17 pub ch_platform: Option<String>,
19 pub ch_mobile: Option<bool>,
21 pub accept_language: Option<String>,
23 pub tls_fingerprint: Option<String>,
25}
26
27impl ClientContext {
28 pub fn new(ip: IpAddr) -> Self {
30 Self {
31 ip: canonical_ip(ip),
32 user_agent: None,
33 ch_platform: None,
34 ch_mobile: None,
35 accept_language: None,
36 tls_fingerprint: None,
37 }
38 }
39
40 #[must_use]
42 pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
43 self.user_agent = Some(ua.into());
44 self
45 }
46
47 #[must_use]
49 pub fn with_ch_platform(mut self, platform: impl AsRef<str>) -> Self {
50 self.ch_platform = Some(platform.as_ref().trim().trim_matches('"').to_owned());
51 self
52 }
53
54 #[must_use]
56 pub fn with_ch_mobile(mut self, mobile: bool) -> Self {
57 self.ch_mobile = Some(mobile);
58 self
59 }
60
61 #[must_use]
63 pub fn with_accept_language(mut self, value: impl Into<String>) -> Self {
64 self.accept_language = Some(value.into());
65 self
66 }
67
68 #[must_use]
70 pub fn with_tls_fingerprint(mut self, value: impl Into<String>) -> Self {
71 self.tls_fingerprint = Some(value.into());
72 self
73 }
74
75 pub(crate) fn from_headers<H: HeaderSource + ?Sized>(
77 ip: IpAddr,
78 headers: &H,
79 tls_header: Option<&str>,
80 max_len: usize,
81 ) -> Self {
82 let get = |name: &str| first_header(headers, name, max_len);
83 Self {
84 ip: canonical_ip(ip),
85 user_agent: get("user-agent"),
86 ch_platform: get("sec-ch-ua-platform")
87 .map(|p| p.trim_matches('"').trim().to_owned())
88 .filter(|p| !p.is_empty()),
89 ch_mobile: get("sec-ch-ua-mobile").and_then(|m| match m.as_str() {
90 "?1" => Some(true),
91 "?0" => Some(false),
92 _ => None,
93 }),
94 accept_language: get("accept-language"),
95 tls_fingerprint: tls_header.and_then(get),
96 }
97 }
98}
99
100pub trait HeaderSource {
105 fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a;
107}
108
109impl HeaderSource for [(&str, &str)] {
110 fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a {
111 self.iter()
112 .filter(move |(k, _)| k.eq_ignore_ascii_case(name))
113 .map(|(_, v)| v.as_bytes())
114 }
115}
116
117impl<const N: usize> HeaderSource for [(&str, &str); N] {
118 fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a {
119 self.as_slice().header_values(name)
120 }
121}
122
123impl HeaderSource for Vec<(&str, &str)> {
124 fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a {
125 self.as_slice().header_values(name)
126 }
127}
128
129#[cfg(feature = "axum")]
130impl HeaderSource for http::HeaderMap {
131 fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a [u8]> + 'a {
132 self.get_all(name).iter().map(http::HeaderValue::as_bytes)
133 }
134}
135
136pub(crate) fn first_header<H: HeaderSource + ?Sized>(headers: &H, name: &str, max_len: usize) -> Option<String> {
139 let raw = headers.header_values(name).next()?;
140 let value = std::str::from_utf8(raw).ok()?.trim();
141 if value.is_empty() {
142 return None;
143 }
144 Some(truncate(value, max_len).to_owned())
145}
146
147pub(crate) fn truncate(s: &str, max: usize) -> &str {
148 if s.len() <= max {
149 return s;
150 }
151 let mut end = max;
152 while !s.is_char_boundary(end) {
153 end -= 1;
154 }
155 &s[..end]
156}
157
158pub fn canonical_ip(ip: IpAddr) -> IpAddr {
160 match ip {
161 IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
162 v4 @ IpAddr::V4(_) => v4,
163 }
164}
165
166pub fn resolve_client_ip<'a>(peer: IpAddr, xff_values: impl IntoIterator<Item = &'a str>, trusted: &[IpNet]) -> IpAddr {
173 let peer = canonical_ip(peer);
174 let is_trusted = |ip: &IpAddr| trusted.iter().any(|net| net.contains(ip));
175 if !is_trusted(&peer) {
176 return peer;
177 }
178 let values: Vec<&str> = xff_values.into_iter().collect();
179 let mut client = peer;
180 for hop in values.iter().rev().flat_map(|v| v.rsplit(',')) {
181 let Some(ip) = parse_hop(hop) else {
182 return peer;
183 };
184 client = ip;
185 if !is_trusted(&ip) {
186 break;
187 }
188 }
189 client
190}
191
192fn parse_hop(hop: &str) -> Option<IpAddr> {
193 let hop = hop.trim();
194 if let Ok(ip) = hop.parse::<IpAddr>() {
195 return Some(canonical_ip(ip));
196 }
197 if let Ok(sock) = hop.parse::<std::net::SocketAddr>() {
199 return Some(canonical_ip(sock.ip()));
200 }
201 None
202}
203
204#[cfg(test)]
205mod tests {
206 use proptest::prelude::*;
207
208 use super::*;
209
210 fn nets(s: &[&str]) -> Vec<IpNet> {
211 s.iter().map(|n| n.parse().unwrap()).collect()
212 }
213
214 fn ip(s: &str) -> IpAddr {
215 s.parse().unwrap()
216 }
217
218 #[test]
219 fn untrusted_peer_ignores_xff() {
220 assert_eq!(resolve_client_ip(ip("1.2.3.4"), ["9.9.9.9"], &[]), ip("1.2.3.4"));
221 assert_eq!(
222 resolve_client_ip(ip("1.2.3.4"), ["9.9.9.9"], &nets(&["10.0.0.0/8"])),
223 ip("1.2.3.4")
224 );
225 }
226
227 #[test]
228 fn walks_right_to_left_past_trusted_hops() {
229 let trusted = nets(&["10.0.0.0/8"]);
230 assert_eq!(
231 resolve_client_ip(ip("10.0.0.1"), ["6.6.6.6, 5.5.5.5, 10.0.0.2"], &trusted),
232 ip("5.5.5.5")
233 );
234 assert_eq!(
235 resolve_client_ip(ip("10.0.0.1"), ["6.6.6.6", "5.5.5.5, 10.0.0.2"], &trusted),
236 ip("5.5.5.5")
237 );
238 }
239
240 #[test]
241 fn spoofed_leftmost_entry_is_ignored() {
242 let trusted = nets(&["10.0.0.0/8"]);
243 assert_eq!(
245 resolve_client_ip(ip("10.0.0.1"), ["1.1.1.1, 7.7.7.7"], &trusted),
246 ip("7.7.7.7")
247 );
248 }
249
250 #[test]
251 fn garbage_falls_back_to_peer() {
252 let trusted = nets(&["10.0.0.0/8"]);
253 assert_eq!(
254 resolve_client_ip(ip("10.0.0.1"), ["7.7.7.7, nope"], &trusted),
255 ip("10.0.0.1")
256 );
257 assert_eq!(resolve_client_ip(ip("10.0.0.1"), [""], &trusted), ip("10.0.0.1"));
258 assert_eq!(
259 resolve_client_ip(ip("10.0.0.1"), ["7.7.7.7,"], &trusted),
260 ip("10.0.0.1")
261 );
262 }
263
264 #[test]
265 fn all_trusted_chain_returns_leftmost() {
266 let trusted = nets(&["10.0.0.0/8"]);
267 assert_eq!(
268 resolve_client_ip(ip("10.0.0.1"), ["10.0.0.3, 10.0.0.2"], &trusted),
269 ip("10.0.0.3")
270 );
271 assert_eq!(
272 resolve_client_ip(ip("10.0.0.1"), Vec::<&str>::new(), &trusted),
273 ip("10.0.0.1")
274 );
275 }
276
277 #[test]
278 fn ports_brackets_and_mapped_addresses() {
279 let trusted = nets(&["10.0.0.0/8", "::1/128"]);
280 assert_eq!(
281 resolve_client_ip(ip("::1"), ["[2001:db8::1]:443"], &trusted),
282 ip("2001:db8::1")
283 );
284 assert_eq!(
285 resolve_client_ip(ip("::ffff:10.0.0.1"), ["8.8.8.8:80"], &trusted),
286 ip("8.8.8.8")
287 );
288 assert_eq!(
289 resolve_client_ip(ip("::ffff:1.2.3.4"), ["8.8.8.8"], &trusted),
290 ip("1.2.3.4")
291 );
292 }
293
294 #[test]
295 fn reads_and_normalises_headers() {
296 let headers = [
297 ("User-Agent", " Mozilla/5.0 "),
298 ("sec-ch-ua-platform", "\"macOS\""),
299 ("Sec-CH-UA-Mobile", "?0"),
300 ("accept-language", "en-US,en;q=0.9"),
301 ("x-ja4", "t13d1516h2_8daaf6152771_02713d6af862"),
302 ];
303 let ctx = ClientContext::from_headers(ip("1.2.3.4"), &headers, Some("x-ja4"), 512);
304 assert_eq!(ctx.user_agent.as_deref(), Some("Mozilla/5.0"));
305 assert_eq!(ctx.ch_platform.as_deref(), Some("macOS"));
306 assert_eq!(ctx.ch_mobile, Some(false));
307 assert_eq!(ctx.accept_language.as_deref(), Some("en-US,en;q=0.9"));
308 assert!(ctx.tls_fingerprint.is_some());
309
310 let ctx = ClientContext::from_headers(
311 ip("1.2.3.4"),
312 &[("sec-ch-ua-mobile", "maybe"), ("user-agent", "")],
313 None,
314 512,
315 );
316 assert_eq!(ctx.ch_mobile, None);
317 assert_eq!(ctx.user_agent, None);
318 assert_eq!(ctx.tls_fingerprint, None);
319 }
320
321 #[test]
322 fn truncates_at_char_boundary() {
323 let long = "é".repeat(400);
324 let headers = [("user-agent", long.as_str())];
325 let ctx = ClientContext::from_headers(ip("1.2.3.4"), &headers, None, 513);
326 assert_eq!(ctx.user_agent.unwrap().len(), 512);
327 }
328
329 proptest! {
330 #[test]
331 fn resolve_never_panics(xff in proptest::collection::vec(".{0,60}", 0..4), v6 in any::<bool>()) {
332 let trusted = nets(&["0.0.0.0/0", "::/0"]);
333 let peer = if v6 { ip("::1") } else { ip("127.0.0.1") };
334 let _ = resolve_client_ip(peer, xff.iter().map(String::as_str), &trusted);
335 }
336
337 #[test]
338 fn untrusted_peer_always_wins(xff in ".{0,60}", a in any::<u32>()) {
339 let peer = IpAddr::V4(a.into());
340 prop_assert_eq!(resolve_client_ip(peer, [xff.as_str()], &[]), peer);
341 }
342 }
343}