Skip to main content

session_guard/
client.rs

1//! What the guard knows about the client making a request.
2
3use std::net::IpAddr;
4
5use ipnet::IpNet;
6
7/// Request attributes used for fingerprinting. Build it with
8/// [`Guard::client_context`](crate::Guard::client_context) from headers, or by hand with
9/// [`ClientContext::new`] and the `with_*` methods.
10#[derive(Clone, Debug, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct ClientContext {
13    /// Client IP after proxy resolution.
14    pub ip: IpAddr,
15    /// `User-Agent`.
16    pub user_agent: Option<String>,
17    /// `Sec-CH-UA-Platform`, without quotes.
18    pub ch_platform: Option<String>,
19    /// `Sec-CH-UA-Mobile`: `?1` is `true`, `?0` is `false`.
20    pub ch_mobile: Option<bool>,
21    /// `Accept-Language`.
22    pub accept_language: Option<String>,
23    /// TLS fingerprint from the configured proxy header.
24    pub tls_fingerprint: Option<String>,
25}
26
27impl ClientContext {
28    /// A context with only an IP address.
29    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    /// Sets the `User-Agent`.
41    #[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    /// Sets the platform client hint (quotes are trimmed).
48    #[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    /// Sets the mobile client hint.
55    #[must_use]
56    pub fn with_ch_mobile(mut self, mobile: bool) -> Self {
57        self.ch_mobile = Some(mobile);
58        self
59    }
60
61    /// Sets `Accept-Language`.
62    #[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    /// Sets the TLS fingerprint.
69    #[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    /// Reads fingerprint headers. `ip` must already be resolved (see [`resolve_client_ip`]).
76    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
100/// Read access to request headers, so the core works without any HTTP library.
101///
102/// Implemented for `[(&str, &str)]` slices (names compared case-insensitively) and, with the
103/// `axum` feature, for `http::HeaderMap`.
104pub trait HeaderSource {
105    /// All values of the header `name` (lowercase), in order.
106    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
136/// First value of `name` as trimmed UTF-8, truncated to `max_len` bytes at a char boundary.
137/// Non-UTF-8 and empty values are `None`.
138pub(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
158/// Maps IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) to IPv4.
159pub 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
166/// Resolves the client IP from the TCP peer and `X-Forwarded-For` values.
167///
168/// `X-Forwarded-For` is only honoured when the peer is a trusted proxy. The chain is walked
169/// right to left, skipping trusted proxies, and the first untrusted address is the client. A
170/// malformed entry stops the walk and the peer address is used, so a client cannot inject an
171/// address by sending a garbage header. Never panics on arbitrary input.
172pub 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    // "[2001:db8::1]:443" or "1.2.3.4:80"
198    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        // The attacker sends "X-Forwarded-For: 1.1.1.1"; the proxy appends the real address.
244        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}