Skip to main content

better_auth_core/config/
client_ip.rs

1use super::IpAddressConfig;
2use std::{collections::HashMap, net::IpAddr};
3
4impl IpAddressConfig {
5    /// Resolve a forwarded client address using the initialized IP policy.
6    ///
7    /// Trusted proxy matching uses the full address before IPv6 grouping.
8    /// A malformed hop fails that header closed; the next configured header
9    /// may still resolve an address. Tracking opt-out also disables fallback.
10    #[must_use]
11    pub fn resolve_ip(&self, headers: &HashMap<String, String>) -> Option<String> {
12        if self.disable_ip_tracking {
13            return None;
14        }
15        let proxies = self
16            .trusted_proxies
17            .iter()
18            .filter_map(|value| Network::parse(value))
19            .collect::<Vec<_>>();
20        self.headers
21            .iter()
22            .filter_map(|name| {
23                headers
24                    .iter()
25                    .find(|(key, _)| key.eq_ignore_ascii_case(name))
26                    .and_then(|(_, value)| self.resolve_header(value, &proxies))
27            })
28            .next()
29            .or_else(|| self.localhost_fallback.then(|| "127.0.0.1".to_owned()))
30    }
31
32    fn resolve_header(&self, value: &str, proxies: &[Network]) -> Option<String> {
33        let hops = value
34            .split(',')
35            .map(|hop| {
36                hop.trim_matches(|character: char| {
37                    (character.is_whitespace() && character != '\u{85}') || character == '\u{feff}'
38                })
39            })
40            .filter(|hop| !hop.is_empty())
41            .collect::<Vec<_>>();
42        if proxies.is_empty() {
43            return if hops.len() == 1 {
44                Address::parse(hops.first()?).map(|ip| ip.normalize(self.ipv6_subnet))
45            } else {
46                None
47            };
48        }
49        for hop in hops.iter().rev() {
50            let ip = Address::parse(hop)?;
51            if !proxies.iter().any(|proxy| proxy.contains(&ip.bytes)) {
52                return Some(ip.normalize(self.ipv6_subnet));
53            }
54        }
55        None
56    }
57}
58
59struct Address {
60    bytes: Vec<u8>,
61    ipv6_groups: Option<Vec<String>>,
62}
63
64impl Address {
65    fn parse(value: &str) -> Option<Self> {
66        match value.parse::<IpAddr>().ok()? {
67            IpAddr::V4(ip) => Some(Self {
68                bytes: ip.octets().to_vec(),
69                ipv6_groups: None,
70            }),
71            IpAddr::V6(ip) => {
72                // The published parser recognizes hexadecimal mapped addresses
73                // only when the marker is spelled `ffff`; dotted mapped
74                // addresses are recognized regardless of marker case.
75                if let Some(mapped) = ip.to_ipv4_mapped().filter(|_| {
76                    value.contains('.') || value.split(':').any(|group| group == "ffff")
77                }) {
78                    return Some(Self {
79                        bytes: mapped.octets().to_vec(),
80                        ipv6_groups: None,
81                    });
82                }
83                let groups = expand_ipv6(value);
84                let bytes = groups
85                    .iter()
86                    .flat_map(|group| segment(group).to_be_bytes())
87                    .collect();
88                Some(Self {
89                    bytes,
90                    ipv6_groups: Some(groups),
91                })
92            }
93        }
94    }
95
96    fn normalize(self, prefix: f64) -> String {
97        let Some(groups) = self.ipv6_groups else {
98            return self
99                .bytes
100                .iter()
101                .map(u8::to_string)
102                .collect::<Vec<_>>()
103                .join(".");
104        };
105        if prefix.is_nan() || prefix >= 128.0 {
106            return groups.join(":").to_lowercase();
107        }
108        let mut bits = prefix.floor().max(0.0);
109        groups
110            .into_iter()
111            .map(|group| {
112                let retained = (0..16_u16)
113                    .filter(|bit| f64::from(*bit) < bits)
114                    .fold(0_u16, |mask, bit| mask | (1 << (15 - bit)));
115                bits -= 16.0;
116                if retained == u16::MAX {
117                    group.to_lowercase()
118                } else {
119                    format!("{:04x}", segment(&group) & retained)
120                }
121            })
122            .collect::<Vec<_>>()
123            .join(":")
124    }
125}
126
127fn expand_ipv6(value: &str) -> Vec<String> {
128    let raw = if let Some((left, right)) = value.split_once("::") {
129        let mut left = left
130            .split(':')
131            .filter(|group| !group.is_empty())
132            .collect::<Vec<_>>();
133        let right = right
134            .split(':')
135            .filter(|group| !group.is_empty())
136            .collect::<Vec<_>>();
137        left.extend(std::iter::repeat_n("0", 8 - left.len() - right.len()));
138        left.extend(right);
139        left
140    } else {
141        value.split(':').collect()
142    };
143    raw.into_iter()
144        .map(|group| format!("{group:0>4}"))
145        .collect()
146}
147
148fn segment(group: &str) -> u16 {
149    let prefix = group.split_once('.').map_or(group, |(first, _)| first);
150    u16::from_str_radix(prefix, 16).unwrap_or_default()
151}
152
153struct Network {
154    bytes: Vec<u8>,
155    prefix: u8,
156}
157
158impl Network {
159    fn parse(value: &str) -> Option<Self> {
160        let (ip, prefix) = value
161            .rsplit_once('/')
162            .map_or((value, None), |(ip, prefix)| (ip, Some(prefix)));
163        let address = Address::parse(ip)?;
164        let maximum = u8::try_from(address.bytes.len() * 8).ok()?;
165        let prefix = if let Some(prefix) = prefix {
166            if prefix.is_empty() || !prefix.bytes().all(|byte| byte.is_ascii_digit()) {
167                return None;
168            }
169            prefix
170                .parse::<u8>()
171                .ok()
172                .filter(|prefix| *prefix <= maximum)?
173        } else {
174            maximum
175        };
176        Some(Self {
177            bytes: address.bytes,
178            prefix,
179        })
180    }
181
182    fn contains(&self, bytes: &[u8]) -> bool {
183        if bytes.len() != self.bytes.len() {
184            return false;
185        }
186        let mut remaining = self.prefix;
187        for (ip, network) in bytes.iter().zip(&self.bytes) {
188            if remaining == 0 {
189                break;
190            }
191            let mask = u8::MAX << (8 - remaining.min(8));
192            if ip & mask != network & mask {
193                return false;
194            }
195            remaining = remaining.saturating_sub(8);
196        }
197        true
198    }
199}
200
201// LCOV_EXCL_START
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn forwarded_chains_select_the_first_untrusted_hop_from_the_right() {
208        for (proxies, chain, expected) in [
209            (vec![], "198.51.100.7", Some("198.51.100.7")),
210            (vec![], "198.51.100.7, 10.0.0.1", None),
211            (vec!["invalid"], "198.51.100.7, 10.0.0.1", None),
212            (
213                vec!["10.0.0.0/8"],
214                "203.0.113.99, 198.51.100.7, 10.0.0.1",
215                Some("198.51.100.7"),
216            ),
217            (vec!["10.0.0.0/8"], "10.0.0.2, 10.0.0.1", None),
218            (
219                vec!["10.0.0.0/8"],
220                "198.51.100.7, malformed, 10.0.0.1",
221                None,
222            ),
223        ] {
224            let config = IpAddressConfig {
225                trusted_proxies: proxies.into_iter().map(str::to_owned).collect(),
226                localhost_fallback: false,
227                ..Default::default()
228            };
229            let headers = HashMap::from([("X-Forwarded-For".into(), chain.into())]);
230            assert_eq!(config.resolve_ip(&headers).as_deref(), expected, "{chain}");
231        }
232    }
233
234    #[test]
235    fn malformed_header_falls_back_but_tracking_opt_out_never_does() {
236        let mut config = IpAddressConfig {
237            headers: vec!["x-forwarded-for".into(), "x-real-ip".into()],
238            trusted_proxies: vec!["10.0.0.0/8".into()],
239            localhost_fallback: true,
240            ..Default::default()
241        };
242        let headers = HashMap::from([
243            (
244                "x-forwarded-for".into(),
245                "198.51.100.7, bad-hop, 10.0.0.1".into(),
246            ),
247            ("x-real-ip".into(), "203.0.113.8".into()),
248        ]);
249        assert_eq!(config.resolve_ip(&headers).as_deref(), Some("203.0.113.8"));
250        assert_eq!(
251            config.resolve_ip(&HashMap::new()).as_deref(),
252            Some("127.0.0.1")
253        );
254        config.disable_ip_tracking = true;
255        assert_eq!(config.resolve_ip(&headers), None);
256        assert_eq!(config.resolve_ip(&HashMap::new()), None);
257    }
258
259    #[test]
260    fn ipv6_proxy_trust_is_checked_before_grouping_the_client_address() {
261        let config = IpAddressConfig {
262            trusted_proxies: vec!["2001:db8:abcd:12::1/128".into()],
263            ipv6_subnet: 64.0,
264            localhost_fallback: false,
265            ..Default::default()
266        };
267        let headers = HashMap::from([(
268            "x-forwarded-for".into(),
269            "203.0.113.99, 2001:db8:abcd:12::2, 2001:db8:abcd:12::1".into(),
270        )]);
271        assert_eq!(
272            config.resolve_ip(&headers).as_deref(),
273            Some("2001:0db8:abcd:0012:0000:0000:0000:0000")
274        );
275    }
276}
277// LCOV_EXCL_STOP