Skip to main content

max / makenotwork

9.8 KB · 254 lines History Blame Raw
1 //! Rate-limit key extraction that never trusts an attacker-controlled
2 //! `X-Forwarded-For`.
3 //!
4 //! `tower_governor`'s `SmartIpKeyExtractor` reads `X-Forwarded-For` from *every*
5 //! request, so any direct client can mint a fresh rate-limit bucket per request
6 //! by rotating the header, defeating every limiter. [`TrustedProxyKeyExtractor`]
7 //! closes that: it honors forwarding headers only when the request's *direct
8 //! peer* is a configured trusted proxy, and otherwise keys on the peer address
9 //! and ignores the headers entirely. There is deliberately **no `Default`**, the
10 //! set of trusted proxies must be supplied explicitly, so "trust this header"
11 //! can never be the silent default that the prior extractor made it.
12 //!
13 //! # Two hops, not one
14 //!
15 //! Rightmost-`X-Forwarded-For` identifies the real client only when exactly one
16 //! trusted proxy sits in front of the app. Prod has two: Cloudflare, then the
17 //! on-host Caddy (`forums.makenot.work` in `MNW/server/deploy/Caddyfile`).
18 //! Cloudflare appends the real client to `X-Forwarded-For`, then Caddy appends
19 //! the Cloudflare edge address, so the rightmost entry is a *Cloudflare* IP.
20 //! Keying on it collapses every visitor routed through the same edge into one
21 //! bucket: per-IP limits stop isolating abusers, and one abuser exhausts the
22 //! bucket shared by everyone behind that edge.
23 //!
24 //! So a trusted peer's `CF-Connecting-IP` wins over `X-Forwarded-For`.
25 //! Cloudflare sets that header to the true client and it survives exactly one
26 //! hop, and the vhost pins TLS client auth to the Cloudflare origin-pull CA
27 //! (`import cloudflare_tls`), so nothing but Cloudflare can reach the origin to
28 //! forge one. Rightmost-XFF stays as the fallback for a single-proxy
29 //! deployment (a tailnet-direct staging box) that has no Cloudflare in front.
30
31 use std::net::{IpAddr, SocketAddr};
32 use std::sync::Arc;
33
34 use axum::http::{HeaderMap, Request};
35 use tower_governor::errors::GovernorError;
36 use tower_governor::key_extractor::KeyExtractor;
37
38 /// A [`KeyExtractor`] that keys on the real client IP, trusting
39 /// `CF-Connecting-IP` / `X-Forwarded-For` only when the direct peer is a known
40 /// proxy.
41 ///
42 /// Construction requires the trusted-proxy set; there is no `Default`. An empty
43 /// set trusts no proxy, every request keys on its direct peer, which is safe
44 /// but collapses to a single global bucket behind a reverse proxy, so a proxied
45 /// deployment must list its proxy (loopback for an on-host Caddy).
46 #[derive(Clone, Debug)]
47 pub struct TrustedProxyKeyExtractor {
48 trusted_proxies: Arc<[IpAddr]>,
49 }
50
51 impl TrustedProxyKeyExtractor {
52 /// Build with the explicit set of proxy IPs whose `X-Forwarded-For` is
53 /// trusted. Trusting the header is a decision the caller must make here.
54 pub fn new(trusted_proxies: impl Into<Arc<[IpAddr]>>) -> Self {
55 Self {
56 trusted_proxies: trusted_proxies.into(),
57 }
58 }
59
60 fn peer_ip<T>(req: &Request<T>) -> Option<IpAddr> {
61 req.extensions()
62 .get::<axum::extract::ConnectInfo<SocketAddr>>()
63 .map(|ci| ci.0.ip())
64 }
65 }
66
67 impl KeyExtractor for TrustedProxyKeyExtractor {
68 type Key = IpAddr;
69
70 // `name`/`key_name` are only trait methods under tower_governor's `tracing`
71 // feature, which this crate does not enable; omitted deliberately.
72
73 fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
74 let peer = Self::peer_ip(req).ok_or(GovernorError::UnableToExtractKey)?;
75 // Only a trusted proxy's forwarding headers are believed. A direct
76 // (untrusted) peer keys on its own socket address, so it cannot spoof
77 // another key.
78 if self.trusted_proxies.contains(&peer) {
79 // CF-Connecting-IP first: under Cloudflare + Caddy it names the true
80 // client, while rightmost-XFF names the Cloudflare edge. See the
81 // module docs.
82 if let Some(client) = cf_connecting_ip(req.headers()) {
83 return Ok(client);
84 }
85 if let Some(client) = rightmost_xff(req.headers()) {
86 return Ok(client);
87 }
88 }
89 Ok(peer)
90 }
91 }
92
93 /// The client address Cloudflare observed, from `CF-Connecting-IP`.
94 ///
95 /// Cloudflare sets exactly one address here (not a chain), replacing any value
96 /// the client sent, so there is no leftmost/rightmost question to get wrong.
97 fn cf_connecting_ip(headers: &HeaderMap) -> Option<IpAddr> {
98 headers
99 .get("cf-connecting-ip")
100 .and_then(|hv| hv.to_str().ok())
101 .and_then(|s| s.trim().parse::<IpAddr>().ok())
102 }
103
104 /// The rightmost parseable `X-Forwarded-For` entry, the address the trusted
105 /// proxy observed connecting to it. Taking the *rightmost* entry (not the
106 /// leftmost) means a client-injected hop to the left of the proxy's appended
107 /// entry is ignored, so spoofing fails even when the proxy appends rather than
108 /// overwrites.
109 fn rightmost_xff(headers: &HeaderMap) -> Option<IpAddr> {
110 headers
111 .get("x-forwarded-for")
112 .and_then(|hv| hv.to_str().ok())
113 .and_then(|s| s.rsplit(',').find_map(|p| p.trim().parse::<IpAddr>().ok()))
114 }
115
116 #[cfg(test)]
117 mod tests {
118 use super::*;
119
120 fn req(peer: Option<&str>, xff: Option<&str>) -> Request<()> {
121 req_cf(peer, xff, None)
122 }
123
124 fn req_cf(peer: Option<&str>, xff: Option<&str>, cf: Option<&str>) -> Request<()> {
125 let mut b = Request::builder();
126 if let Some(xff) = xff {
127 b = b.header("x-forwarded-for", xff);
128 }
129 if let Some(cf) = cf {
130 b = b.header("cf-connecting-ip", cf);
131 }
132 let mut req = b.body(()).unwrap();
133 if let Some(peer) = peer {
134 let addr: SocketAddr = format!("{peer}:1234").parse().unwrap();
135 req.extensions_mut()
136 .insert(axum::extract::ConnectInfo(addr));
137 }
138 req
139 }
140
141 fn extractor(trusted: &[&str]) -> TrustedProxyKeyExtractor {
142 let ips: Vec<IpAddr> = trusted.iter().map(|s| s.parse().unwrap()).collect();
143 TrustedProxyKeyExtractor::new(ips)
144 }
145
146 #[test]
147 fn trusted_peer_uses_forwarded_client() {
148 // Peer is the trusted proxy → believe XFF (rightmost = real client).
149 let key = extractor(&["127.0.0.1"])
150 .extract(&req(Some("127.0.0.1"), Some("203.0.113.7")))
151 .unwrap();
152 assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap());
153 }
154
155 #[test]
156 fn untrusted_peer_ignores_forwarded_header() {
157 // A direct client spoofing XFF gets keyed on its OWN address, not the
158 // forged one, the whole point of the fix.
159 let key = extractor(&["127.0.0.1"])
160 .extract(&req(Some("198.51.100.9"), Some("203.0.113.7")))
161 .unwrap();
162 assert_eq!(key, "198.51.100.9".parse::<IpAddr>().unwrap());
163 }
164
165 #[test]
166 fn trusted_peer_no_header_falls_back_to_peer() {
167 let key = extractor(&["127.0.0.1"])
168 .extract(&req(Some("127.0.0.1"), None))
169 .unwrap();
170 assert_eq!(key, "127.0.0.1".parse::<IpAddr>().unwrap());
171 }
172
173 #[test]
174 fn rightmost_entry_defeats_injected_left_hop() {
175 // Attacker sends "1.2.3.4", proxy appends the real client on the right.
176 // Rightmost wins, so the injected left entry is ignored.
177 let key = extractor(&["127.0.0.1"])
178 .extract(&req(Some("127.0.0.1"), Some("1.2.3.4, 203.0.113.7")))
179 .unwrap();
180 assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap());
181 }
182
183 #[test]
184 fn trusted_peer_garbage_header_falls_back_to_peer() {
185 let key = extractor(&["127.0.0.1"])
186 .extract(&req(Some("127.0.0.1"), Some("not-an-ip")))
187 .unwrap();
188 assert_eq!(key, "127.0.0.1".parse::<IpAddr>().unwrap());
189 }
190
191 #[test]
192 fn empty_trusted_set_always_keys_on_peer() {
193 let key = extractor(&[])
194 .extract(&req(Some("203.0.113.7"), Some("10.0.0.1")))
195 .unwrap();
196 assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap());
197 }
198
199 #[test]
200 fn cf_connecting_ip_beats_xff_for_a_trusted_peer() {
201 // The prod shape: Cloudflare appended the real client to XFF, then Caddy
202 // appended the Cloudflare edge. Rightmost-XFF would key on the edge and
203 // bucket every visitor behind it together; CF-Connecting-IP is the client.
204 let key = extractor(&["127.0.0.1"])
205 .extract(&req_cf(
206 Some("127.0.0.1"),
207 Some("203.0.113.7, 172.68.1.1"),
208 Some("203.0.113.7"),
209 ))
210 .unwrap();
211 assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap());
212 }
213
214 #[test]
215 fn untrusted_peer_ignores_cf_connecting_ip() {
216 // A direct client forging the Cloudflare header still keys on itself.
217 let key = extractor(&["127.0.0.1"])
218 .extract(&req_cf(Some("198.51.100.9"), None, Some("203.0.113.7")))
219 .unwrap();
220 assert_eq!(key, "198.51.100.9".parse::<IpAddr>().unwrap());
221 }
222
223 #[test]
224 fn trusted_peer_garbage_cf_header_falls_back_to_xff() {
225 // An unparseable CF header must not shadow a usable XFF chain.
226 let key = extractor(&["127.0.0.1"])
227 .extract(&req_cf(
228 Some("127.0.0.1"),
229 Some("203.0.113.7"),
230 Some("not-an-ip"),
231 ))
232 .unwrap();
233 assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap());
234 }
235
236 #[test]
237 fn trusted_peer_cf_header_alone_is_enough() {
238 // Caddy can be configured to strip XFF; CF-Connecting-IP still keys.
239 let key = extractor(&["127.0.0.1"])
240 .extract(&req_cf(Some("127.0.0.1"), None, Some("203.0.113.7")))
241 .unwrap();
242 assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap());
243 }
244
245 #[test]
246 fn no_peer_errors() {
247 assert!(
248 extractor(&["127.0.0.1"])
249 .extract(&req(None, Some("203.0.113.7")))
250 .is_err()
251 );
252 }
253 }
254