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}