Skip to main content

guard_core_engine/
ip_extraction.rs

1//! The client-IP extraction from the forwarded chain: the reference
2//! `_utils/ip_extraction.py`'s depth-limited `X-Forwarded-For` walk, driven
3//! by the `trusted_proxies` / `trusted_proxy_depth` knobs.
4//!
5//! Semantics, mirroring `_resolve_client_ip_from_forwarded_chain` exactly:
6//!
7//! ```text
8//! no header:                        the connecting peer is the client
9//! chain shorter than the depth:     the connecting peer (warn once)
10//! right side holds unlisted peers:  walk right to left, skip trusted
11//!                                   entries, first untrusted wins (warn)
12//! otherwise:                        the entry `depth` from the right,
13//!                                   port-stripped and canonicalized
14//! ```
15//!
16//! A selected entry that is itself on the trusted list surfaces the
17//! over-declared-depth warning as data ([`ChainWarnings`]) - the engine
18//! has no logger, so the once-only warnings ride the result for the
19//! caller to log. Unparseable entries and metacharacter candidates
20//! (`*?[]\`) resolve back to the connecting peer, never to a spoof.
21//!
22//! # Example
23//!
24//! ```
25//! use guard_core_engine::ip_extraction::{extract_client_ip, ClientIpSource};
26//!
27//! // A trusted proxy's claim is honored one hop deep.
28//! let extraction = extract_client_ip(
29//!     Some("10.0.0.1"),
30//!     Some("203.0.113.7"),
31//!     &["10.0.0.0/8".to_owned()],
32//!     1,
33//! );
34//! assert_eq!(extraction.client_ip, "203.0.113.7");
35//! assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
36//!
37//! // An untrusted peer's header is ignored (the spoof signal rides along).
38//! let extraction = extract_client_ip(
39//!     Some("192.0.2.9"),
40//!     Some("203.0.113.7"),
41//!     &["10.0.0.0/8".to_owned()],
42//!     1,
43//! );
44//! assert_eq!(extraction.client_ip, "192.0.2.9");
45//! assert!(extraction.untrusted_forwarded_header);
46//!
47//! // No trusted proxies at all: the header never decides.
48//! let extraction = extract_client_ip(Some("192.0.2.9"), Some("203.0.113.7"), &[], 1);
49//! assert_eq!(extraction.client_ip, "192.0.2.9");
50//! assert_eq!(extraction.source, ClientIpSource::Connecting);
51//! ```
52
53use std::net::IpAddr;
54use std::str::FromStr;
55
56use crate::ip_gate::IpNet;
57
58/// The reference `UNKNOWN_CLIENT_IDENTITY`: the identity a request with no
59/// connecting peer carries.
60pub const UNKNOWN_CLIENT_IDENTITY: &str = "unknown";
61
62/// Where the extracted client identity came from (the reference
63/// `extract_client_ip`'s arms).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ClientIpSource {
66    /// The connecting peer itself (not a trusted proxy, or nothing to walk).
67    Connecting,
68    /// Resolved from the `X-Forwarded-For` chain at the declared depth.
69    ForwardedChain,
70    /// No connecting peer at all (the `unknown` identity, or the `unix`
71    /// proxy arm walking from unknown).
72    Unknown,
73}
74
75/// The once-only warnings the reference logs, surfaced as data (the engine
76/// has no logger; the caller logs and de-duplicates).
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub struct ChainWarnings {
79    /// The chain had fewer entries than `trusted_proxy_depth` (the
80    /// reference `_warn_forwarded_header_chain_too_short`).
81    pub chain_too_short: bool,
82    /// The declared depth over-counts the real hops: `Some(count)` of
83    /// right-side entries are not on the trusted list (the reference
84    /// `_warn_forwarded_header_depth_overcounts_hops`).
85    pub depth_overcounts: Option<usize>,
86    /// The selected entry is itself a trusted proxy (the reference
87    /// `_warn_forwarded_header_selected_entry_trusted_proxy`).
88    pub selected_entry_trusted: bool,
89}
90
91/// One extraction: the identity plus where it came from and what the
92/// caller should log.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ClientIpExtraction {
95    /// The resolved client identity (`UNKNOWN_CLIENT_IDENTITY` when the
96    /// request has no peer and the `unix` arm does not apply).
97    pub client_ip: String,
98    /// Which arm produced the identity.
99    pub source: ClientIpSource,
100    /// An untrusted peer sent an `X-Forwarded-For` header (the reference's
101    /// spoof-attempt signal: the header is ignored, the event is the
102    /// caller's to emit).
103    pub untrusted_forwarded_header: bool,
104    /// The chain-walk warnings (empty unless [`ClientIpSource::
105    /// ForwardedChain`] or the too-short arm ran).
106    pub warnings: ChainWarnings,
107}
108
109/// `_strip_ip_brackets`.
110#[must_use]
111fn strip_ip_brackets(value: &str) -> &str {
112    value
113        .strip_prefix('[')
114        .and_then(|rest| rest.strip_suffix(']'))
115        .unwrap_or(value)
116}
117
118/// `_strip_forwarded_entry_port`: `[v6]:port` and `host:port` lose the
119/// port; anything else passes through untouched.
120#[must_use]
121pub fn strip_forwarded_entry_port(value: &str) -> &str {
122    if let Some(rest) = value.strip_prefix('[') {
123        let Some(closing) = rest.find(']') else {
124            return value;
125        };
126        let remainder = &rest[closing + 1..];
127        let port_form = remainder.len() > 1
128            && remainder.starts_with(':')
129            && remainder[1..].chars().all(|c| c.is_ascii_digit());
130        if remainder.is_empty() || port_form {
131            return &rest[..closing];
132        }
133        return value;
134    }
135    match value.split_once(':') {
136        Some((host, port))
137            if !host.contains(':')
138                && !port.is_empty()
139                && port.chars().all(|c| c.is_ascii_digit()) =>
140        {
141            host
142        }
143        _ => value,
144    }
145}
146
147/// An address literal as the reference `ip_address` reads it: the address,
148/// plus the IPv6 zone identifier when the literal carries one (`fe80::1%eth0`).
149/// `std` parses no zones, so the zone is split off here.
150fn parse_ip_literal(value: &str) -> Option<(IpAddr, Option<&str>)> {
151    match value.split_once('%') {
152        Some((address, zone)) if !zone.is_empty() && address.contains(':') => {
153            let addr = IpAddr::from_str(address).ok()?;
154            addr.is_ipv6().then_some((addr, Some(zone)))
155        }
156        Some(_) => None,
157        None => IpAddr::from_str(value).ok().map(|addr| (addr, None)),
158    }
159}
160
161/// `_canonical_ip_text` over a parsed literal: an IPv4-mapped address
162/// collapses to its IPv4 text unless it carries a zone (the reference keeps
163/// the zoned spelling distinct).
164fn literal_text(addr: IpAddr, zone: Option<&str>) -> String {
165    zone.map_or_else(|| canonical_ip_text(addr), |zone| format!("{addr}%{zone}"))
166}
167
168/// `_canonicalize_ip`: canonical text for a parseable address (an
169/// IPv4-mapped IPv6 collapses to its IPv4 text), the raw value otherwise.
170#[must_use]
171pub fn canonicalize_ip(value: &str) -> String {
172    parse_ip_literal(strip_ip_brackets(value))
173        .map_or_else(|| value.to_owned(), |(addr, zone)| literal_text(addr, zone))
174}
175
176/// `_canonical_ip_text`.
177#[must_use]
178pub fn canonical_ip_text(addr: IpAddr) -> String {
179    match addr {
180        IpAddr::V6(v6) => v6
181            .to_ipv4_mapped()
182            .map_or_else(|| v6.to_string(), |mapped| mapped.to_string()),
183        IpAddr::V4(v4) => v4.to_string(),
184    }
185}
186
187/// `_forwarded_header_candidate_has_metachar`.
188#[must_use]
189pub fn candidate_has_metachar(candidate: &str) -> bool {
190    candidate
191        .chars()
192        .any(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
193}
194
195/// `_forwarded_header_ips`: the entries, comma-split, port-stripped.
196#[must_use]
197pub fn forwarded_header_ips(forwarded_for: &str) -> Vec<String> {
198    forwarded_for
199        .split(',')
200        .map(|entry| strip_forwarded_entry_port(entry.trim()).to_owned())
201        .collect()
202}
203
204/// `_proxy_matches` / `_is_trusted_proxy`.
205///
206/// The connecting text must parse as an address; a bare entry then compares
207/// as text (the `unix` token never matches a real peer, and an IPv4-mapped
208/// spelling is a different text from its IPv4 form), a CIDR entry contains
209/// the address within its own family.
210#[must_use]
211pub fn is_trusted_proxy(connecting: &str, trusted_proxies: &[String]) -> bool {
212    let Some((addr, _zone)) = parse_ip_literal(connecting) else {
213        return false;
214    };
215    trusted_proxies.iter().any(|proxy| {
216        if proxy.contains('/') {
217            IpNet::parse(proxy).is_some_and(|network| network.contains(addr))
218        } else {
219            connecting == proxy
220        }
221    })
222}
223
224/// `_forwarded_header_candidate_addr`: the parsed candidate and its text.
225#[must_use]
226fn candidate_addr(candidate: &str) -> Option<(IpAddr, Option<&str>)> {
227    parse_ip_literal(candidate)
228}
229
230/// `_forwarded_header_right_side_unlisted_count`.
231#[must_use]
232fn right_side_unlisted_count(
233    ips: &[String],
234    proxy_depth: u32,
235    trusted_proxies: &[String],
236) -> usize {
237    let depth = usize::try_from(proxy_depth).unwrap_or(ips.len());
238    let start = ips.len().saturating_sub(depth.saturating_sub(1));
239    ips[start..]
240        .iter()
241        .filter(|entry| !is_trusted_proxy(&canonicalize_ip(entry), trusted_proxies))
242        .count()
243}
244
245/// `_resolve_forwarded_chain_right_to_left`: the first untrusted entry
246/// from the right, `None` when only trusted entries remain or a candidate
247/// is unparseable or carries a metacharacter.
248#[must_use]
249fn resolve_chain_right_to_left(ips: &[String], trusted_proxies: &[String]) -> Option<String> {
250    ips.iter()
251        .rev()
252        .find(|entry| !is_trusted_proxy(&canonicalize_ip(entry), trusted_proxies))
253        .and_then(|entry| {
254            // Metachar first: a metachar candidate never parses as an
255            // address, so the reference's parse-then-metachar pair has
256            // this exact truth table (the reference keeps the order only
257            // for readability).
258            if candidate_has_metachar(entry) {
259                return None;
260            }
261            candidate_addr(entry).map(|_| canonicalize_ip(entry))
262        })
263}
264
265/// `_resolve_client_ip_from_forwarded_chain`: the depth-limited walk with
266/// the warnings as data. Failures and short chains degrade to the
267/// connecting identity, never to a header claim.
268#[must_use]
269pub fn resolve_client_ip_from_forwarded_chain(
270    canonical_connecting: &str,
271    forwarded_for: Option<&str>,
272    proxy_depth: u32,
273    trusted_proxies: &[String],
274) -> ClientIpExtraction {
275    let mut warnings = ChainWarnings::default();
276    let Some(forwarded_for) = forwarded_for.filter(|value| !value.is_empty()) else {
277        return ClientIpExtraction {
278            client_ip: canonical_connecting.to_owned(),
279            source: ClientIpSource::Connecting,
280            untrusted_forwarded_header: false,
281            warnings,
282        };
283    };
284
285    let ips = forwarded_header_ips(forwarded_for);
286    let chain_length = ips.len();
287
288    let depth = usize::try_from(proxy_depth).unwrap_or(0);
289    if depth == 0 || chain_length < depth {
290        warnings.chain_too_short = true;
291        return ClientIpExtraction {
292            client_ip: canonical_connecting.to_owned(),
293            source: ClientIpSource::Connecting,
294            untrusted_forwarded_header: false,
295            warnings,
296        };
297    }
298
299    if !trusted_proxies.is_empty() {
300        let unlisted = right_side_unlisted_count(&ips, proxy_depth, trusted_proxies);
301        if unlisted > 0 {
302            warnings.depth_overcounts = Some(unlisted);
303            let Some(resolved) = resolve_chain_right_to_left(&ips, trusted_proxies) else {
304                return ClientIpExtraction {
305                    client_ip: canonical_connecting.to_owned(),
306                    source: ClientIpSource::Connecting,
307                    untrusted_forwarded_header: false,
308                    warnings,
309                };
310            };
311            return ClientIpExtraction {
312                client_ip: resolved,
313                source: ClientIpSource::ForwardedChain,
314                untrusted_forwarded_header: false,
315                warnings,
316            };
317        }
318    }
319
320    // The depth gate above guarantees an entry at `depth` from the right.
321    let candidate = ips[chain_length - depth].as_str();
322    // Metachar first: a metachar candidate never parses as an address, so
323    // the reference's parse-then-metachar pair has this exact truth table.
324    if candidate_has_metachar(candidate) || candidate_addr(candidate).is_none() {
325        return ClientIpExtraction {
326            client_ip: canonical_connecting.to_owned(),
327            source: ClientIpSource::Connecting,
328            untrusted_forwarded_header: false,
329            warnings,
330        };
331    }
332
333    let client_ip = canonicalize_ip(candidate);
334    warnings.selected_entry_trusted = is_trusted_proxy(&client_ip, trusted_proxies);
335    ClientIpExtraction {
336        client_ip,
337        source: ClientIpSource::ForwardedChain,
338        untrusted_forwarded_header: false,
339        warnings,
340    }
341}
342
343/// `extract_client_ip`: the reference entry point. `client_host` is the
344/// connecting peer (`None` for a socket connection); `forwarded_for` the
345/// raw `X-Forwarded-For` header.
346#[must_use]
347pub fn extract_client_ip(
348    client_host: Option<&str>,
349    forwarded_for: Option<&str>,
350    trusted_proxies: &[String],
351    proxy_depth: u32,
352) -> ClientIpExtraction {
353    // An empty peer is no peer (the reference tests `not request.client_host`).
354    let Some(client_host) = client_host.filter(|host| !host.is_empty()) else {
355        // The socket arm: with `unix` on the proxy list the chain is still
356        // walked, from the unknown identity (the reference `_handle` arm).
357        if trusted_proxies.iter().any(|proxy| proxy == "unix") {
358            let mut extraction = resolve_client_ip_from_forwarded_chain(
359                UNKNOWN_CLIENT_IDENTITY,
360                forwarded_for,
361                proxy_depth,
362                trusted_proxies,
363            );
364            if extraction.source == ClientIpSource::Connecting {
365                UNKNOWN_CLIENT_IDENTITY.clone_into(&mut extraction.client_ip);
366                extraction.source = ClientIpSource::Unknown;
367            }
368            return extraction;
369        }
370        return ClientIpExtraction {
371            client_ip: UNKNOWN_CLIENT_IDENTITY.to_owned(),
372            source: ClientIpSource::Unknown,
373            untrusted_forwarded_header: false,
374            warnings: ChainWarnings::default(),
375        };
376    };
377
378    let canonical_connecting = canonicalize_ip(client_host);
379
380    if trusted_proxies.is_empty() {
381        // The preempted-header warning is the caller's (it rides the flag).
382        return ClientIpExtraction {
383            client_ip: canonical_connecting,
384            source: ClientIpSource::Connecting,
385            untrusted_forwarded_header: forwarded_for.is_some_and(|value| !value.is_empty()),
386            warnings: ChainWarnings::default(),
387        };
388    }
389
390    if !is_trusted_proxy(client_host, trusted_proxies) {
391        return ClientIpExtraction {
392            client_ip: canonical_connecting,
393            source: ClientIpSource::Connecting,
394            untrusted_forwarded_header: forwarded_for.is_some_and(|value| !value.is_empty()),
395            warnings: ChainWarnings::default(),
396        };
397    }
398
399    resolve_client_ip_from_forwarded_chain(
400        &canonical_connecting,
401        forwarded_for,
402        proxy_depth,
403        trusted_proxies,
404    )
405}
406
407/// The status of the missing-client-address rejection (the reference
408/// `BypassHandler.handle_passthrough` 403).
409pub const UNRESOLVABLE_CLIENT_ADDRESS_STATUS: u16 = 403;
410
411/// The body of the missing-client-address rejection.
412pub const UNRESOLVABLE_CLIENT_ADDRESS_MESSAGE: &str = "Client address could not be determined";
413
414/// The check name the rejection reports to the `on_block` hook
415/// (`UNRESOLVABLE_CLIENT_ADDRESS_CHECK_NAME`).
416pub const UNRESOLVABLE_CLIENT_ADDRESS_CHECK_NAME: &str = "client_address_unresolved";
417
418/// One request's client resolution: the identity the pipeline runs under
419/// and whether `fail_secure` rejects the request outright.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct ClientResolution {
422    /// The extraction behind the identity (source, spoof flag, warnings).
423    pub extraction: ClientIpExtraction,
424    /// The identity as an address; `None` for the `unknown` identity (the
425    /// pipeline then runs unattributed, the reference's `"unknown"` run).
426    pub ip: Option<IpAddr>,
427    /// A peer-less request that resolves to `unknown` under `fail_secure`:
428    /// answer 403 [`UNRESOLVABLE_CLIENT_ADDRESS_MESSAGE`] before any check
429    /// (the reference `handle_passthrough`).
430    pub reject: bool,
431}
432
433/// The reference client resolution an adapter runs per request.
434///
435/// `extract_client_ip` over the connecting `peer`, the raw
436/// `X-Forwarded-For` header, `trusted_proxies` and `trusted_proxy_depth`,
437/// plus the `handle_passthrough` rule for a request with no peer address
438/// (`fail_secure` rejects it unless the `unix` proxy token lets the chain
439/// resolve it).
440///
441/// # Example
442///
443/// ```
444/// use guard_core_engine::ip_extraction::resolve_client;
445///
446/// // A peer-less request under fail_secure is rejected.
447/// assert!(resolve_client(None, None, &[], 1, true).reject);
448/// // A trusted proxy's chain decides the client.
449/// let resolution = resolve_client(
450///     Some("10.0.0.1".parse().unwrap()),
451///     Some("203.0.113.7"),
452///     &["10.0.0.0/8".to_owned()],
453///     1,
454///     true,
455/// );
456/// assert_eq!(resolution.ip, Some("203.0.113.7".parse().unwrap()));
457/// ```
458#[must_use]
459pub fn resolve_client(
460    peer: Option<IpAddr>,
461    forwarded_for: Option<&str>,
462    trusted_proxies: &[String],
463    proxy_depth: u32,
464    fail_secure: bool,
465) -> ClientResolution {
466    let peer_text = peer.map(|addr| addr.to_string());
467    let extraction = extract_client_ip(
468        peer_text.as_deref(),
469        forwarded_for,
470        trusted_proxies,
471        proxy_depth,
472    );
473    let unknown = extraction.client_ip == UNKNOWN_CLIENT_IDENTITY;
474    let ip = if unknown {
475        None
476    } else {
477        // A zoned identity has no `IpAddr` form (the zone is part of the identity).
478        parse_ip_literal(&extraction.client_ip)
479            .and_then(|(addr, zone)| zone.is_none().then_some(addr))
480    };
481    ClientResolution {
482        reject: peer.is_none() && unknown && fail_secure,
483        extraction,
484        ip,
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    fn proxies(entries: &[&str]) -> Vec<String> {
493        entries.iter().map(ToString::to_string).collect()
494    }
495
496    #[test]
497    fn the_depth_selects_the_entry_from_the_right() {
498        // One appending proxy (depth 1): the client is the last entry.
499        let extraction = extract_client_ip(
500            Some("10.0.0.1"),
501            Some("203.0.113.7"),
502            &proxies(&["10.0.0.0/8"]),
503            1,
504        );
505        assert_eq!(extraction.client_ip, "203.0.113.7");
506        assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
507        assert!(!extraction.warnings.selected_entry_trusted);
508
509        // Two appending proxies (depth 3, both hops trusted): the client
510        // is third from the right.
511        let extraction = extract_client_ip(
512            Some("10.0.0.1"),
513            Some("203.0.113.7, 198.51.100.4, 10.0.0.1"),
514            &proxies(&["10.0.0.0/8", "198.51.100.0/24"]),
515            3,
516        );
517        assert_eq!(extraction.client_ip, "203.0.113.7");
518
519        // A depth that lands on a trusted entry still answers it, with
520        // the over-declared-depth warning (the reference warns and
521        // returns the selected entry).
522        let extraction = extract_client_ip(
523            Some("10.0.0.1"),
524            Some("203.0.113.7, 10.0.0.1"),
525            &proxies(&["10.0.0.0/8"]),
526            1,
527        );
528        assert_eq!(extraction.client_ip, "10.0.0.1");
529        assert!(extraction.warnings.selected_entry_trusted);
530    }
531
532    #[test]
533    fn a_short_chain_falls_back_to_the_connecting_peer() {
534        let extraction = extract_client_ip(
535            Some("10.0.0.1"),
536            Some("203.0.113.7"),
537            &proxies(&["10.0.0.0/8"]),
538            3,
539        );
540        assert_eq!(extraction.client_ip, "10.0.0.1");
541        assert_eq!(extraction.source, ClientIpSource::Connecting);
542        assert!(extraction.warnings.chain_too_short);
543    }
544
545    #[test]
546    fn an_over_counting_depth_walks_right_to_left() {
547        // The declared depth counts a hop the trusted list does not
548        // cover: an entry to the right of the selection is unlisted, the
549        // walk skips trusted entries and answers the first untrusted one.
550        let extraction = extract_client_ip(
551            Some("10.0.0.1"),
552            Some("203.0.113.7, 198.51.100.4, 10.0.0.1"),
553            &proxies(&["10.0.0.0/8"]),
554            3,
555        );
556        assert_eq!(extraction.client_ip, "198.51.100.4");
557        assert_eq!(extraction.warnings.depth_overcounts, Some(1));
558
559        // A depth whose right side is fully trusted never walks.
560        let extraction = extract_client_ip(
561            Some("10.0.0.1"),
562            Some("10.0.0.2, 10.0.0.1"),
563            &proxies(&["10.0.0.0/8"]),
564            1,
565        );
566        assert_eq!(extraction.client_ip, "10.0.0.1");
567        assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
568        assert_eq!(extraction.warnings.depth_overcounts, None);
569    }
570
571    #[test]
572    fn unparseable_and_metachar_candidates_degrade_to_connecting() {
573        // The depth-selected entry is unparseable: connecting wins.
574        let extraction = extract_client_ip(
575            Some("10.0.0.1"),
576            Some("not-an-ip"),
577            &proxies(&["10.0.0.0/8"]),
578            1,
579        );
580        assert_eq!(extraction.client_ip, "10.0.0.1");
581
582        // The depth-selected entry carries a metacharacter: connecting.
583        let extraction = extract_client_ip(
584            Some("10.0.0.1"),
585            Some("*.evil.example"),
586            &proxies(&["10.0.0.0/8"]),
587            1,
588        );
589        assert_eq!(extraction.client_ip, "10.0.0.1");
590
591        // The right-to-left walk refuses a metachar candidate too: the
592        // rightmost untrusted entry is malformed, so the walk has no
593        // answer and the connecting identity stands.
594        let extraction = extract_client_ip(
595            Some("10.0.0.1"),
596            Some("203.0.113.7, *.evil.example, 10.0.0.1"),
597            &proxies(&["10.0.0.0/8"]),
598            3,
599        );
600        assert_eq!(extraction.client_ip, "10.0.0.1");
601        assert_eq!(extraction.warnings.depth_overcounts, Some(1));
602    }
603
604    #[test]
605    fn a_selected_trusted_entry_warns() {
606        let extraction = extract_client_ip(
607            Some("10.0.0.1"),
608            Some("10.0.0.9, 10.0.0.1"),
609            &proxies(&["10.0.0.0/8"]),
610            2,
611        );
612        assert_eq!(extraction.client_ip, "10.0.0.9");
613        assert!(extraction.warnings.selected_entry_trusted);
614    }
615
616    #[test]
617    fn untrusted_peers_and_absent_proxies_ignore_the_header() {
618        let extraction = extract_client_ip(
619            Some("192.0.2.9"),
620            Some("203.0.113.7"),
621            &proxies(&["10.0.0.0/8"]),
622            1,
623        );
624        assert_eq!(extraction.client_ip, "192.0.2.9");
625        assert_eq!(extraction.source, ClientIpSource::Connecting);
626        assert!(extraction.untrusted_forwarded_header);
627
628        let extraction = extract_client_ip(Some("192.0.2.9"), Some("203.0.113.7"), &[], 1);
629        assert_eq!(extraction.client_ip, "192.0.2.9");
630        assert!(extraction.untrusted_forwarded_header);
631
632        // No header at all: no spoof signal.
633        let extraction = extract_client_ip(Some("192.0.2.9"), None, &proxies(&["10.0.0.0/8"]), 1);
634        assert!(!extraction.untrusted_forwarded_header);
635    }
636
637    #[test]
638    fn canonicalization_strips_brackets_ports_and_v4_mapping() {
639        assert_eq!(
640            strip_forwarded_entry_port("[2001:db8::1]:443"),
641            "2001:db8::1"
642        );
643        assert_eq!(strip_forwarded_entry_port("2001:db8::1"), "2001:db8::1");
644        assert_eq!(
645            strip_forwarded_entry_port("203.0.113.7:8443"),
646            "203.0.113.7"
647        );
648        assert_eq!(strip_forwarded_entry_port("203.0.113.7"), "203.0.113.7");
649        // A bare v6 with one colon-free host:port shape stays intact.
650        assert_eq!(strip_forwarded_entry_port("2001:db8::1"), "2001:db8::1");
651        assert_eq!(canonicalize_ip("[2001:db8::1]"), "2001:db8::1");
652        assert_eq!(canonicalize_ip("::ffff:203.0.113.7"), "203.0.113.7");
653        assert_eq!(canonicalize_ip("not-an-ip"), "not-an-ip");
654    }
655
656    #[test]
657    fn the_unknown_arm_walks_only_for_unix_proxies() {
658        let extraction = extract_client_ip(None, Some("203.0.113.7"), &proxies(&["unix"]), 1);
659        assert_eq!(extraction.client_ip, "203.0.113.7");
660        assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
661
662        let extraction = extract_client_ip(None, Some("203.0.113.7"), &[], 1);
663        assert_eq!(extraction.client_ip, UNKNOWN_CLIENT_IDENTITY);
664        assert_eq!(extraction.source, ClientIpSource::Unknown);
665
666        // The unix walk degrading to connecting reports the unknown identity.
667        let extraction = extract_client_ip(None, None, &proxies(&["unix"]), 1);
668        assert_eq!(extraction.client_ip, UNKNOWN_CLIENT_IDENTITY);
669        assert_eq!(extraction.source, ClientIpSource::Unknown);
670    }
671
672    #[test]
673    fn malformed_bracket_entries_pass_through_untouched() {
674        // An unterminated bracket form is not an address.
675        assert_eq!(strip_forwarded_entry_port("[2001:db8::1"), "[2001:db8::1");
676        // A bracket form with trailing junk past the closing bracket is
677        // not `host]:port` either.
678        assert_eq!(
679            strip_forwarded_entry_port("[2001:db8::1]junk"),
680            "[2001:db8::1]junk"
681        );
682        // The clean forms still strip.
683        assert_eq!(strip_forwarded_entry_port("[2001:db8::1]"), "2001:db8::1");
684        assert_eq!(
685            strip_forwarded_entry_port("[2001:db8::1]:443"),
686            "2001:db8::1"
687        );
688    }
689
690    #[test]
691    fn an_empty_trusted_list_still_walks_at_the_declared_depth() {
692        // Direct resolve with no proxy list: the depth arm answers the
693        // entry (the caller decided trust before calling).
694        let extraction =
695            resolve_client_ip_from_forwarded_chain("10.0.0.1", Some("203.0.113.7"), 1, &[]);
696        assert_eq!(extraction.client_ip, "203.0.113.7");
697        assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
698        assert_eq!(extraction.warnings, ChainWarnings::default());
699    }
700
701    #[test]
702    fn an_unparseable_rightmost_untrusted_entry_ends_the_walk() {
703        // The rightmost untrusted entry does not parse: the walk has no
704        // answer and the connecting identity stands.
705        let extraction = extract_client_ip(
706            Some("10.0.0.1"),
707            Some("203.0.113.7, not-an-ip, 10.0.0.1"),
708            &proxies(&["10.0.0.0/8"]),
709            3,
710        );
711        assert_eq!(extraction.client_ip, "10.0.0.1");
712        assert_eq!(extraction.warnings.depth_overcounts, Some(1));
713    }
714
715    #[test]
716    fn the_unix_token_never_matches_a_real_peer() {
717        assert!(!is_trusted_proxy("127.0.0.1", &proxies(&["unix"])));
718        assert!(is_trusted_proxy(
719            "10.1.2.3",
720            &proxies(&["unix", "10.0.0.0/8"])
721        ));
722        assert!(is_trusted_proxy("10.1.2.3", &proxies(&["10.1.2.3"])));
723    }
724
725    /// `(peer, X-Forwarded-For, trusted_proxies, depth, identity)`, recorded
726    /// from the reference `extract_client_ip`.
727    #[allow(clippy::type_complexity)]
728    const REFERENCE: &[(Option<&str>, Option<&str>, &[&str], u32, &str)] = &[
729        (
730            Some("::ffff:10.0.0.1"),
731            Some("203.0.113.7"),
732            &["10.0.0.0/8"],
733            1,
734            "10.0.0.1",
735        ),
736        (
737            Some("::ffff:10.0.0.1"),
738            Some("203.0.113.7"),
739            &["10.0.0.1"],
740            1,
741            "10.0.0.1",
742        ),
743        (
744            Some("10.0.0.1"),
745            Some("203.0.113.7"),
746            &["10.0.0.0/8"],
747            1,
748            "203.0.113.7",
749        ),
750        (
751            Some("::FFFF:10.0.0.1"),
752            Some("203.0.113.7"),
753            &[],
754            1,
755            "10.0.0.1",
756        ),
757        (Some("FE80::1%eth0"), None, &[], 1, "fe80::1%eth0"),
758        (Some("fe80::1%eth0"), None, &[], 1, "fe80::1%eth0"),
759        (Some("2001:DB8::1"), None, &[], 1, "2001:db8::1"),
760        (
761            Some("10.0.0.1"),
762            Some("[2001:db8::1]:443"),
763            &["10.0.0.1"],
764            1,
765            "2001:db8::1",
766        ),
767        (None, Some("203.0.113.7"), &["unix"], 1, "203.0.113.7"),
768        (None, Some("203.0.113.7"), &[], 1, "unknown"),
769        (None, None, &["unix"], 1, "unknown"),
770        (Some(""), Some("203.0.113.7"), &["unix"], 1, "203.0.113.7"),
771        (Some("not-an-ip"), None, &[], 1, "not-an-ip"),
772        (
773            Some("10.0.0.1"),
774            Some("1.1.1.1, 203.0.113.7"),
775            &["10.0.0.1"],
776            2,
777            "203.0.113.7",
778        ),
779        (
780            Some("10.0.0.1"),
781            Some("203.0.113.7"),
782            &["10.0.0.1"],
783            3,
784            "10.0.0.1",
785        ),
786        (Some("::1"), Some("203.0.113.7"), &["::1"], 1, "203.0.113.7"),
787        (
788            Some("0:0:0:0:0:ffff:a00:1"),
789            Some("9.9.9.9"),
790            &["10.0.0.1"],
791            1,
792            "10.0.0.1",
793        ),
794    ];
795
796    #[test]
797    fn identities_follow_the_reference_table() {
798        for (index, (peer, forwarded, proxy_list, depth, expected)) in REFERENCE.iter().enumerate()
799        {
800            let extraction = extract_client_ip(*peer, *forwarded, &proxies(proxy_list), *depth);
801            assert_eq!(extraction.client_ip, *expected, "reference row {index}");
802        }
803    }
804
805    #[test]
806    fn a_peerless_request_is_rejected_only_when_unresolved_and_fail_secure() {
807        let rejected = resolve_client(None, Some("203.0.113.7"), &[], 1, true);
808        assert!(rejected.reject);
809        assert_eq!(rejected.ip, None);
810        assert_eq!(rejected.extraction.source, ClientIpSource::Unknown);
811
812        let open = resolve_client(None, Some("203.0.113.7"), &[], 1, false);
813        assert!(!open.reject);
814        assert_eq!(open.ip, None);
815
816        let unix = resolve_client(None, Some("203.0.113.7"), &proxies(&["unix"]), 1, true);
817        assert!(!unix.reject);
818        assert_eq!(unix.ip, Some("203.0.113.7".parse().unwrap()));
819    }
820
821    #[test]
822    fn a_peer_resolves_through_the_trusted_chain_and_never_rejects() {
823        let resolution = resolve_client(
824            Some("10.0.0.1".parse().unwrap()),
825            Some("203.0.113.7"),
826            &proxies(&["10.0.0.0/8"]),
827            1,
828            true,
829        );
830        assert!(!resolution.reject);
831        assert_eq!(resolution.ip, Some("203.0.113.7".parse().unwrap()));
832
833        let untrusted = resolve_client(
834            Some("192.0.2.9".parse().unwrap()),
835            Some("203.0.113.7"),
836            &proxies(&["10.0.0.0/8"]),
837            1,
838            true,
839        );
840        assert_eq!(untrusted.ip, Some("192.0.2.9".parse().unwrap()));
841        assert!(untrusted.extraction.untrusted_forwarded_header);
842
843        // An IPv4-mapped peer canonicalizes to its IPv4 identity.
844        let mapped = resolve_client(
845            Some("::ffff:192.0.2.9".parse().unwrap()),
846            None,
847            &[],
848            1,
849            true,
850        );
851        assert_eq!(mapped.ip, Some("192.0.2.9".parse().unwrap()));
852    }
853
854    #[test]
855    fn a_zoned_chain_entry_keeps_its_text_but_has_no_address() {
856        let resolution = resolve_client(None, Some("fe80::1%eth0"), &proxies(&["unix"]), 1, true);
857        assert_eq!(resolution.extraction.client_ip, "fe80::1%eth0");
858        assert_eq!(resolution.ip, None);
859        assert!(!resolution.reject);
860    }
861
862    #[test]
863    fn a_malformed_zone_literal_is_not_an_address() {
864        // A zone on an IPv4 literal, an empty zone, and a non-address with a
865        // zone-shaped tail all pass through as raw text and never trust.
866        for raw in ["1.2.3.4%eth0", "fe80::1%", "not:an:ip%eth0"] {
867            assert_eq!(canonicalize_ip(raw), raw);
868            assert!(!is_trusted_proxy(raw, &proxies(&["fe80::/10", raw])));
869        }
870        // A well-formed zoned literal is an address and matches its network.
871        assert!(is_trusted_proxy("fe80::1%eth0", &proxies(&["fe80::/10"])));
872    }
873}