Skip to main content

cratestack_axum/trusted_proxy/
config.rs

1use std::net::IpAddr;
2
3use ipnet::IpNet;
4
5/// Which single forwarding header a trusted proxy is expected to write.
6///
7/// RFC 7239 `Forwarded` and the legacy `X-Forwarded-For` are alternatives,
8/// not complements — a real reverse proxy (nginx, an AWS ALB, HAProxy's
9/// defaults) is configured to emit **one** of the two, never both
10/// meaningfully. Trusting whichever one happens to be present on the wire
11/// is exactly the bypass this type exists to close: an attacker who knows
12/// a deployment trusts `X-Forwarded-For` can simply add a `Forwarded`
13/// header instead — until this field existed, that header was honored
14/// unconditionally over `X-Forwarded-For` with no hop-count or
15/// trusted-peer check ever applied to it (#415 remediation). Naming the
16/// header explicitly, defaulting to the header real proxies actually send,
17/// closes that gap: the rarer header only takes effect when a deployment
18/// opts into it because its own proxy is actually configured to emit it.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum ForwardedHeader {
21    /// The legacy header, still what the overwhelming majority of real
22    /// deployments emit (nginx's `proxy_set_header X-Forwarded-For`, AWS
23    /// ALB, HAProxy's defaults). The safe default.
24    #[default]
25    XForwardedFor,
26    /// RFC 7239 `Forwarded`. Select this only when the deployment's own
27    /// trusted proxy is actually configured to emit `Forwarded` instead of
28    /// `X-Forwarded-For` — most are not.
29    Forwarded,
30}
31
32/// Which peers are trusted to set `Forwarded`/`X-Forwarded-For`, how many
33/// hops into the chain to trust when they are, and which of the two
34/// headers to honor.
35///
36/// Applied by the consumer as a plain `Extension<TrustedProxyConfig>`
37/// (`.layer(Extension(config))`) on every router the app serves —
38/// resolved inline inside [`crate::headers::enrich_context_from_headers`]
39/// rather than through a bespoke `tower::Layer`/`Service` pair (Option A',
40/// `docs/design/trusted-proxy-client-ip.md`).
41///
42/// The default ([`TrustedProxyConfig::none`], and the behavior when no
43/// `Extension` is applied at all) trusts nothing: `Forwarded`/
44/// `X-Forwarded-For` are never honored. `enrich_context_from_headers`
45/// falls back to the verified socket peer address (via
46/// `axum::extract::ConnectInfo<SocketAddr>`) when one is available, or
47/// records no `client_ip` at all when it isn't — never guessing, never
48/// trusting an unverified header. See decision 3 in the design doc.
49#[derive(Clone, Debug, Default)]
50pub struct TrustedProxyConfig {
51    allowlist: Vec<IpNet>,
52    max_hops: usize,
53    header: ForwardedHeader,
54}
55
56impl TrustedProxyConfig {
57    /// Trust nothing. Equivalent to omitting the `Extension` entirely —
58    /// provided as an explicit, self-documenting constructor for callers
59    /// who want to state the choice rather than rely on absence.
60    pub fn none() -> Self {
61        Self::default()
62    }
63
64    /// Trust the given peers (exact host addresses or CIDR ranges) as
65    /// reverse proxies. `max_hops` defaults to `1` (a single trusted
66    /// proxy) — call [`Self::max_hops`] to widen it for a chain of
67    /// several trusted proxies (e.g. CDN + load balancer, both
68    /// configured here). The forwarding header defaults to
69    /// [`ForwardedHeader::XForwardedFor`] — call [`Self::forwarded_header`]
70    /// if the deployment's proxy actually emits RFC 7239 `Forwarded`
71    /// instead.
72    ///
73    /// A bare host address can be supplied via `IpAddr`'s `Into<IpNet>`
74    /// impl (a full-length /32 or /128 prefix): `IpAddr::from(...).into()`.
75    pub fn trusting(allowlist: impl IntoIterator<Item = IpNet>) -> Self {
76        Self {
77            allowlist: allowlist.into_iter().collect(),
78            max_hops: 1,
79            header: ForwardedHeader::default(),
80        }
81    }
82
83    /// How many entries, counted from the right (proxy) end of the
84    /// `Forwarded`/`X-Forwarded-For` chain, are trusted to have been
85    /// appended by a trusted proxy.
86    ///
87    /// This **must** be interpreted right-to-left: the entry taken is the
88    /// one `max_hops` positions in from the right end, not the
89    /// `max_hops`-th entry from the left. The left end of the chain is
90    /// exactly the part an untrusted client controls — walking from the
91    /// left re-opens the spoofing gap this type exists to close for any
92    /// chain with more than one hop. See decision 5 in
93    /// `docs/design/trusted-proxy-client-ip.md`.
94    pub fn max_hops(mut self, max_hops: usize) -> Self {
95        self.max_hops = max_hops;
96        self
97    }
98
99    /// Select which single header this deployment's trusted proxy writes.
100    /// See [`ForwardedHeader`]'s doc for why only one is ever honored.
101    pub fn forwarded_header(mut self, header: ForwardedHeader) -> Self {
102        self.header = header;
103        self
104    }
105
106    pub(crate) fn hop_count(&self) -> usize {
107        self.max_hops
108    }
109
110    pub(crate) fn header(&self) -> ForwardedHeader {
111        self.header
112    }
113
114    /// Whether `peer` — the verified socket peer address, never a
115    /// client-suppliable value — is a configured trusted proxy.
116    pub fn is_trusted(&self, peer: IpAddr) -> bool {
117        self.allowlist.iter().any(|net| net.contains(&peer))
118    }
119}