Skip to main content

cratestack_axum/headers/
forwarded.rs

1use std::net::{IpAddr, SocketAddr};
2
3use axum::http::HeaderMap;
4
5use crate::trusted_proxy::ForwardedHeader;
6
7/// Extract the client IP `max_hops` entries in from the *right* end of
8/// whichever single header `header` selects — never both. Falls back to
9/// `None` if that header is absent, empty, or the walk runs off the end of
10/// the chain.
11///
12/// **Only one header is ever consulted (#415 remediation).** RFC 7239
13/// `Forwarded` and the legacy `X-Forwarded-For` are alternatives, not
14/// complements — a real proxy emits one or the other, never both
15/// meaningfully. Consulting `Forwarded` whenever it happens to be present,
16/// ahead of `X-Forwarded-For`, let an attacker who knows a deployment
17/// trusts `X-Forwarded-For` bypass every hop-count/allowlist check just by
18/// sending an entirely unvalidated `Forwarded` header instead. `header`
19/// (from [`crate::trusted_proxy::TrustedProxyConfig::forwarded_header`])
20/// names the one header this deployment's proxy actually writes; the other
21/// is never even inspected.
22///
23/// Callers must only invoke this with a `max_hops`/`header` they have
24/// independently established is trustworthy (i.e. after confirming the
25/// request's socket peer is a configured trusted proxy) — this function
26/// has no notion of trust itself, it only walks the chain. See
27/// [`crate::trusted_proxy::TrustedProxyConfig`] and
28/// [`crate::headers::enrich_context_from_headers`] for the trust check,
29/// and [`crate::headers::enrich_context_from_headers`] for the IP-shape
30/// validation applied to whatever this function selects.
31///
32/// **Right-to-left, not left-to-right.** The left end of the chain is
33/// exactly the part an untrusted client controls (it can prepend arbitrary
34/// entries), so walking in from the left re-opens the identical spoofing
35/// gap for any chain longer than one hop. `max_hops` counts inward from
36/// the right: `max_hops = 1` takes the rightmost entry (the immediate
37/// trusted proxy's own contribution); `max_hops = 2` takes the
38/// second-from-right entry (what the *next* hop in reported seeing),
39/// and so on. `max_hops = 0` trusts nothing and always returns `None`.
40/// See decision 5 in `docs/design/trusted-proxy-client-ip.md`.
41///
42/// **Duplicate header occurrences are merged, not dropped (#415
43/// remediation).** RFC 7230 §3.2.2: repeated list-type header fields are
44/// semantically equivalent to a single comma-joined value. A proxy that
45/// appends its hop as a *second* `X-Forwarded-For` line (rather than
46/// extending the first) must not have that value silently lost to
47/// whichever line an attacker sent first — every occurrence is
48/// concatenated, in wire order, before the chain is walked.
49pub fn parse_client_ip(
50    headers: &HeaderMap,
51    max_hops: usize,
52    header: ForwardedHeader,
53) -> Option<String> {
54    let entries = match header {
55        ForwardedHeader::XForwardedFor => list_header_entries(headers, "x-forwarded-for"),
56        ForwardedHeader::Forwarded => forwarded_for_entries(headers),
57    };
58    select_hop(&entries, max_hops)
59}
60
61/// Every comma-separated value across all occurrences of a list-type
62/// header, concatenated in wire order (RFC 7230 §3.2.2 — see this module's
63/// doc for why that matters here).
64fn list_header_entries(headers: &HeaderMap, name: &str) -> Vec<String> {
65    headers
66        .get_all(name)
67        .iter()
68        .filter_map(|v| v.to_str().ok())
69        .flat_map(|raw| raw.split(','))
70        .map(str::trim)
71        .filter(|s| !s.is_empty())
72        .map(str::to_owned)
73        .collect()
74}
75
76/// The ordered `for=` values across every occurrence of the RFC 7239
77/// `Forwarded` header's comma-separated segments, left-to-right as they
78/// appear on the wire (occurrences merged per RFC 7230 §3.2.2, same as
79/// [`list_header_entries`]).
80fn forwarded_for_entries(headers: &HeaderMap) -> Vec<String> {
81    headers
82        .get_all("forwarded")
83        .iter()
84        .filter_map(|v| v.to_str().ok())
85        .flat_map(|raw| raw.split(','))
86        .filter_map(|segment| {
87            segment.split(';').map(str::trim).find_map(|kv| {
88                let rest = kv.strip_prefix("for=")?;
89                // Strip the RFC 7239 quoted-string form (`for="..."`); the
90                // bracket/port shape inside is normalized later by
91                // `parse_hop_ip`, once a hop is actually selected.
92                let cleaned = rest.trim_matches('"');
93                (!cleaned.is_empty()).then(|| cleaned.to_owned())
94            })
95        })
96        .collect()
97}
98
99/// Select the entry `max_hops` positions in from the right end of an
100/// ordered (left-to-right, as on the wire) chain. `max_hops = 0` yields
101/// `None` (trust nothing); a `max_hops` deeper than the chain's actual
102/// length also yields `None` rather than guessing which shorter-than-
103/// expected entry might still be trustworthy — a chain shorter than the
104/// configured hop count is treated as unexpected, not as license to fall
105/// back to the leftmost (client-controlled) entry.
106fn select_hop(entries: &[String], max_hops: usize) -> Option<String> {
107    if max_hops == 0 {
108        return None;
109    }
110    let index = entries.len().checked_sub(max_hops)?;
111    entries.get(index).cloned()
112}
113
114/// Parse a single selected hop entry into a validated [`IpAddr`] — the
115/// realistic forms a proxy or client actually sends: a bare address, an
116/// IPv4 address with a port suffix (`1.2.3.4:5678`), and a bracketed IPv6
117/// address with or without a port (`[::1]`, `[::1]:8080`). Returns `None`
118/// for anything else, including malformed/spoofed strings like
119/// `666.666.666.666` or RFC 7239 placeholders (`unknown`, `_hidden`) —
120/// callers must never record an unparseable string as the audit
121/// `client_ip` (#415 remediation, Finding 2).
122pub(super) fn parse_hop_ip(raw: &str) -> Option<IpAddr> {
123    let raw = raw.trim();
124    if let Some(rest) = raw.strip_prefix('[') {
125        // Bracketed IPv6, with or without a trailing `:port` — take
126        // everything up to the closing bracket.
127        let inner = rest.split(']').next()?;
128        return inner.parse().ok();
129    }
130    if let Ok(ip) = raw.parse::<IpAddr>() {
131        return Some(ip);
132    }
133    // IPv4 with a port suffix (`1.2.3.4:5678`); bare IPv6 without brackets
134    // never reaches here successfully since it isn't a valid `SocketAddr`
135    // without brackets, which is correct — unbracketed IPv6 is ambiguous
136    // with a trailing port and is already handled by the bare-`IpAddr`
137    // branch above.
138    raw.parse::<SocketAddr>().ok().map(|addr| addr.ip())
139}