Skip to main content

waf_core/network/
client_ip.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Secure resolution of the real client IP behind trusted proxies.
5//!
6//! A L7 WAF almost always sits behind an LB/CDN/TLS-terminator, so the peer
7//! socket address is the proxy's IP — keying rate limiting or logging on it
8//! collapses all clients to one bucket. The `X-Forwarded-For` chain carries the
9//! real client, but it is **attacker-controlled** unless we count hops from our
10//! own trusted proxies.
11//!
12//! Resolution order (the order IS the security boundary):
13//! 1. peer NOT in `trusted_proxies` → use peer (forwarded header ignored).
14//! 2. peer IS trusted → read the header, take the IP `trusted_hops` from the
15//!    RIGHT (closest to us). NEVER the leftmost IP (client-controlled).
16//! 3. header missing/malformed, or chain shorter than `trusted_hops` → fall back
17//!    to peer (NEVER to a spoofable IP).
18//! 4. `trusted_proxies` empty (default) → always use peer; an unconfigured
19//!    deploy must not be spoofable.
20
21use std::net::IpAddr;
22use std::str::FromStr;
23
24use crate::NetworkConfig;
25
26/// How `client_ip` was determined — for audit/logging and to decide warnings.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum IpSource {
29    /// Peer is not a trusted proxy → peer used; forwarded header ignored.
30    DirectPeer,
31    /// Peer is a trusted proxy → IP read from the forwarded header.
32    TrustedHeader,
33    /// Behind a trusted proxy but the header was absent → fell back to peer.
34    FallbackMissingHeader,
35    /// Behind a trusted proxy but the header was malformed, OR the chain was
36    /// shorter than `trusted_hops` → fell back to peer (never the spoofable IP).
37    FallbackMalformed,
38}
39
40/// Result of resolution: the chosen IP and how it was obtained.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct ResolvedClientIp {
43    pub ip: IpAddr,
44    pub source: IpSource,
45}
46
47/// A parsed CIDR block (IPv4 or IPv6) supporting prefix-masked membership.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum Cidr {
50    V4(u32, u8),
51    V6(u128, u8),
52}
53
54impl Cidr {
55    /// Parse `"10.0.0.0/8"`, `"fd00::/8"`, or a bare address (`"::1"`,
56    /// `"127.0.0.1"`) which implies a full-length prefix (/32 or /128).
57    /// std parses the address (incl. compressed IPv6); only the prefix is ours.
58    fn parse(s: &str) -> Option<Cidr> {
59        let (addr_part, prefix_part) = match s.trim().split_once('/') {
60            Some((a, p)) => (a.trim(), Some(p.trim())),
61            None => (s.trim(), None),
62        };
63        match IpAddr::from_str(addr_part).ok()? {
64            IpAddr::V4(a) => {
65                let prefix = match prefix_part {
66                    Some(p) => p.parse().ok().filter(|&p| p <= 32)?,
67                    None => 32,
68                };
69                Some(Cidr::V4(u32::from(a), prefix))
70            }
71            IpAddr::V6(a) => {
72                let prefix = match prefix_part {
73                    Some(p) => p.parse().ok().filter(|&p| p <= 128)?,
74                    None => 128,
75                };
76                Some(Cidr::V6(u128::from(a), prefix))
77            }
78        }
79    }
80
81    /// Membership test. A family mismatch (v4 vs v6) never matches.
82    fn contains(&self, ip: IpAddr) -> bool {
83        match (self, ip) {
84            (Cidr::V4(net, prefix), IpAddr::V4(addr)) => mask_eq_u32(*net, u32::from(addr), *prefix),
85            (Cidr::V6(net, prefix), IpAddr::V6(addr)) => {
86                mask_eq_u128(*net, u128::from(addr), *prefix)
87            }
88            _ => false,
89        }
90    }
91}
92
93/// Top-`prefix`-bits comparison. The shift edges are handled EXPLICITLY:
94/// `/0` → mask 0 (matches all), `/32` → full mask. This avoids `u32 << 32`,
95/// which panics in debug builds.
96fn mask_eq_u32(a: u32, b: u32, prefix: u8) -> bool {
97    let mask = match prefix {
98        0 => 0,
99        32 => u32::MAX,
100        p => u32::MAX << (32 - p),
101    };
102    (a & mask) == (b & mask)
103}
104
105/// IPv6 counterpart; `/0` → 0, `/128` → full mask (avoids `u128 << 128`).
106fn mask_eq_u128(a: u128, b: u128, prefix: u8) -> bool {
107    let mask = match prefix {
108        0 => 0,
109        128 => u128::MAX,
110        p => u128::MAX << (128 - p),
111    };
112    (a & mask) == (b & mask)
113}
114
115/// True if `s` is a syntactically valid CIDR or bare IP (IPv4/IPv6). Used by
116/// config validation to reject illegal `trusted_proxies` entries at startup
117/// instead of silently dropping them at resolver-construction time.
118pub fn is_valid_cidr(s: &str) -> bool {
119    Cidr::parse(s).is_some()
120}
121
122/// Pre-compiled resolver: trusted CIDRs are parsed once at construction.
123pub struct ClientIpResolver {
124    trusted: Vec<Cidr>,
125    header: String, // lowercased for case-insensitive lookup
126    hops: usize,
127}
128
129impl ClientIpResolver {
130    /// Build from config, parsing the trusted CIDRs once. Invalid CIDR strings
131    /// are skipped (a misconfigured entry must not silently widen trust).
132    pub fn from_config(cfg: &NetworkConfig) -> Self {
133        let trusted = cfg.trusted_proxies.iter().filter_map(|s| Cidr::parse(s)).collect();
134        Self {
135            trusted,
136            header: cfg.client_ip_header.to_ascii_lowercase(),
137            hops: cfg.trusted_hops,
138        }
139    }
140
141    /// Number of valid trusted CIDRs (lets the caller warn if some were dropped).
142    pub fn trusted_count(&self) -> usize {
143        self.trusted.len()
144    }
145
146    /// Resolve the real client IP from the peer address and request headers.
147    pub fn resolve(&self, peer: IpAddr, headers: &[(String, String)]) -> ResolvedClientIp {
148        // Steps 1 & 4: peer not trusted (or nothing trusted) → never trust XFF.
149        if !self.trusted.iter().any(|c| c.contains(peer)) {
150            return ResolvedClientIp { ip: peer, source: IpSource::DirectPeer };
151        }
152
153        // Step 3a: header absent behind a trusted proxy → fall back to peer.
154        let Some(value) = self.header_value(headers) else {
155            return ResolvedClientIp { ip: peer, source: IpSource::FallbackMissingHeader };
156        };
157
158        // The chain is "client, proxy1, proxy2" (left = closest to client = most
159        // spoofable). Count `trusted_hops` from the RIGHT.
160        let chain: Vec<&str> =
161            value.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
162
163        // Step 3b: chain shorter than the hops we trust (or hops=0) → fall back
164        // to peer. Crucially we do NOT pick the leftmost available IP: that is the
165        // first bypass an attacker would try (e.g. hops=2 but XFF="attacker").
166        if self.hops == 0 || self.hops > chain.len() {
167            return ResolvedClientIp { ip: peer, source: IpSource::FallbackMalformed };
168        }
169
170        let idx = chain.len() - self.hops;
171        match IpAddr::from_str(chain[idx]) {
172            Ok(ip) => ResolvedClientIp { ip, source: IpSource::TrustedHeader },
173            // Step 3c: the trusted-position token is not an IP → fall back to peer.
174            Err(_) => ResolvedClientIp { ip: peer, source: IpSource::FallbackMalformed },
175        }
176    }
177
178    /// First header value matching the configured name (case-insensitive).
179    fn header_value<'a>(&self, headers: &'a [(String, String)]) -> Option<&'a str> {
180        headers
181            .iter()
182            .find(|(k, _)| k.eq_ignore_ascii_case(&self.header))
183            .map(|(_, v)| v.as_str())
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn resolver(trusted: &[&str], hops: usize) -> ClientIpResolver {
192        ClientIpResolver::from_config(&NetworkConfig {
193            trusted_proxies: trusted.iter().map(|s| s.to_string()).collect(),
194            client_ip_header: "x-forwarded-for".to_string(),
195            trusted_hops: hops,
196        })
197    }
198
199    fn ip(s: &str) -> IpAddr {
200        s.parse().unwrap()
201    }
202
203    fn xff(v: &str) -> Vec<(String, String)> {
204        vec![("x-forwarded-for".to_string(), v.to_string())]
205    }
206
207    // ── CIDR masking edges ────────────────────────────────────────────────────
208
209    #[test]
210    fn cidr_v4_prefixes() {
211        assert!(Cidr::parse("10.0.0.0/8").unwrap().contains(ip("10.255.1.2")));
212        assert!(!Cidr::parse("10.0.0.0/8").unwrap().contains(ip("11.0.0.1")));
213        // /32 exact (no shift overflow)
214        assert!(Cidr::parse("127.0.0.1").unwrap().contains(ip("127.0.0.1")));
215        assert!(!Cidr::parse("127.0.0.1").unwrap().contains(ip("127.0.0.2")));
216        // /0 matches everything in-family
217        assert!(Cidr::parse("0.0.0.0/0").unwrap().contains(ip("203.0.113.7")));
218    }
219
220    #[test]
221    fn cidr_v6_prefixes() {
222        assert!(Cidr::parse("::1").unwrap().contains(ip("::1")));
223        assert!(!Cidr::parse("::1").unwrap().contains(ip("::2")));
224        assert!(Cidr::parse("fd00::/8").unwrap().contains(ip("fd12:3456::1")));
225        assert!(!Cidr::parse("fd00::/8").unwrap().contains(ip("fe80::1")));
226        // /0 matches everything in-family
227        assert!(Cidr::parse("::/0").unwrap().contains(ip("2001:db8::1")));
228    }
229
230    #[test]
231    fn cidr_family_mismatch_never_matches() {
232        assert!(!Cidr::parse("0.0.0.0/0").unwrap().contains(ip("::1")));
233        assert!(!Cidr::parse("::/0").unwrap().contains(ip("1.2.3.4")));
234    }
235
236    #[test]
237    fn invalid_cidr_is_skipped() {
238        let r = resolver(&["not-an-ip", "10.0.0.0/8", "10.0.0.0/99"], 1);
239        assert_eq!(r.trusted_count(), 1);
240    }
241
242    // ── resolution logic ──────────────────────────────────────────────────────
243
244    #[test]
245    fn untrusted_peer_uses_peer_ignoring_xff() {
246        let r = resolver(&["10.0.0.0/8"], 1);
247        let got = r.resolve(ip("203.0.113.9"), &xff("1.2.3.4"));
248        assert_eq!(got.ip, ip("203.0.113.9"));
249        assert_eq!(got.source, IpSource::DirectPeer);
250    }
251
252    #[test]
253    fn trusted_peer_takes_hop_from_right_not_leftmost() {
254        let r = resolver(&["10.0.0.0/8"], 1);
255        // "attacker, real-lb": leftmost is attacker-controlled; hops=1 → real-lb.
256        let got = r.resolve(ip("10.0.0.5"), &xff("attacker, 198.51.100.7"));
257        assert_eq!(got.ip, ip("198.51.100.7"));
258        assert_eq!(got.source, IpSource::TrustedHeader);
259    }
260
261    #[test]
262    fn empty_trusted_proxies_always_uses_peer() {
263        let r = resolver(&[], 1);
264        let got = r.resolve(ip("10.0.0.5"), &xff("1.2.3.4"));
265        assert_eq!(got.ip, ip("10.0.0.5"));
266        assert_eq!(got.source, IpSource::DirectPeer);
267    }
268
269    #[test]
270    fn malformed_xff_behind_trusted_falls_back_to_peer() {
271        let r = resolver(&["10.0.0.0/8"], 1);
272        let got = r.resolve(ip("10.0.0.5"), &xff("not-an-ip"));
273        assert_eq!(got.ip, ip("10.0.0.5"));
274        assert_eq!(got.source, IpSource::FallbackMalformed);
275    }
276
277    #[test]
278    fn missing_header_behind_trusted_falls_back_to_peer() {
279        let r = resolver(&["10.0.0.0/8"], 1);
280        let got = r.resolve(ip("10.0.0.5"), &[]);
281        assert_eq!(got.ip, ip("10.0.0.5"));
282        assert_eq!(got.source, IpSource::FallbackMissingHeader);
283    }
284
285    #[test]
286    fn chain_shorter_than_hops_never_picks_leftmost() {
287        // The first bypass an attacker tries: hops=2 but only one IP present.
288        // Must fall back to peer, NOT to "attacker".
289        let r = resolver(&["10.0.0.0/8"], 2);
290        let got = r.resolve(ip("10.0.0.5"), &xff("attacker"));
291        assert_eq!(got.ip, ip("10.0.0.5"));
292        assert_eq!(got.source, IpSource::FallbackMalformed);
293    }
294
295    #[test]
296    fn ipv6_trusted_peer_reads_xff() {
297        let r = resolver(&["::1"], 1);
298        let got = r.resolve(ip("::1"), &xff("198.51.100.7"));
299        assert_eq!(got.ip, ip("198.51.100.7"));
300        assert_eq!(got.source, IpSource::TrustedHeader);
301    }
302
303    #[test]
304    fn two_hops_selects_correct_position() {
305        let r = resolver(&["10.0.0.0/8"], 2);
306        // "client, real-lb, edge-lb" with hops=2 from the right → real-lb.
307        let got = r.resolve(ip("10.0.0.5"), &xff("9.9.9.9, 198.51.100.7, 10.0.0.9"));
308        assert_eq!(got.ip, ip("198.51.100.7"));
309        assert_eq!(got.source, IpSource::TrustedHeader);
310    }
311}