Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/http/fwd.rs

20.8 KiB, 37 runs

created by r1870400018:21868, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The forwarding headers a proxy hop owns, and who is allowed to speak them.
2//!
3//! A reverse proxy sits in front of upstream applications and tells each one where the request came
4//! from. It does that with `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host` and the RFC
5//! 7239 `Forwarded` field. Those four are assertions about the hop, and an assertion about the hop
6//! is only worth anything if the hop is the one making it.
7//!
8//! Nothing stops a caller sending its own. If a caller's copy is forwarded and the hop's is appended
9//! after it, the upstream receives two values -- the caller's first, the hop's second -- and the
10//! obvious way to read a header returns the first. [`HeaderFields::get_one`] returns `list[0]`, so an
11//! upstream doing the obvious thing reads whatever the caller invented.
12//! [`HeaderFields::get_last`](crate::http::fields::HeaderFields::get_last) is the reader that
13//! belongs with these headers, and it exists because of this module.
14//!
15//! What that costs is not a weaker limit but no limit at all. An address guard keyed on the first
16//! `X-Forwarded-For` counts a fresh allowance for every fresh invented address, while looking
17//! configured. An upstream reading the first `X-Forwarded-Proto` believes a TLS request arrived in
18//! plaintext, and one that redirects plaintext to HTTPS on that basis loops.
19//!
20//! So this module strips all four from the caller before the hop appends its own -- unless the
21//! immediate peer is a configured trusted proxy, in which case the caller's chain is preserved and
22//! the hop's value appended to it, which is what makes a CDN work. See [`ForwardedPolicy`].
23//!
24//! The invariant every reader may rely on: **this hop's own value is last, under either policy.**
25//! With an untrusted peer there is exactly one value and it is the hop's; with a trusted peer the
26//! caller's chain is kept and the hop's is appended after it. "Read the last value" is therefore a
27//! correct instruction for an upstream whatever the policy says.
28//!
29//! [`HeaderFields::get_one`]: crate::http::fields::HeaderFields::get_one
30//!
31//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
32//! Anthropic Claude
33
34use crate::http::msg::HttpMessage;
35
36use oxedyne_fe2o3_core::prelude::*;
37
38use std::net::{
39 IpAddr,
40 SocketAddr,
41};
42
43
44// The forwarding headers a hop owns, lowercased for comparison. A caller's copy
45// of any of them is dropped unless the peer is trusted, because each is a claim
46// about the hop the request took and only the hop can make it honestly.
47pub const FORWARDED_HEADERS: [&str; 4] = [
48 "x-forwarded-for",
49 "x-forwarded-proto",
50 "x-forwarded-host",
51 "forwarded",
52];
53
54// The headers a hop rewrites for itself, lowercased for comparison. `Host` names
55// the upstream rather than the caller's original; `Connection` and
56// `Transfer-Encoding` are hop-by-hop; `Content-Length` is recomputed from the body
57// actually sent. Passing a caller's `Transfer-Encoding` across a hop that does not
58// re-chunk is a request smuggling primitive, which is why it is on this list
59// rather than left to each call site.
60pub const MANAGED_HEADERS: [&str; 4] = [
61 "host",
62 "connection",
63 "content-length",
64 "transfer-encoding",
65];
66
67/// Is this one of the forwarding headers a hop owns?
68///
69/// The comparison is ASCII case-insensitive. Header names are one name whatever their case, and a
70/// case-sensitive test here is how a caller gets a forged `X-FORWARDED-FOR` past the strip.
71pub fn is_forwarded_header(name: &str) -> bool {
72 FORWARDED_HEADERS.iter().any(|held| name.eq_ignore_ascii_case(held))
73}
74
75/// Is this one of the headers a hop rewrites for itself?
76///
77/// Case-insensitive, for the same reason as [`is_forwarded_header`].
78pub fn is_managed_header(name: &str) -> bool {
79 MANAGED_HEADERS.iter().any(|held| name.eq_ignore_ascii_case(held))
80}
81
82/// A peer whose forwarding headers are believed.
83///
84/// Written in configuration either as a bare address, `198.51.100.7`, or as a prefix,
85/// `198.51.100.0/24`. A content delivery network publishes its egress as prefixes, so a form that
86/// only accepts single addresses would need hundreds of lines to express one CDN.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub enum TrustedPeer {
89 Addr(IpAddr), // one exact address
90 Prefix { // every address sharing the leading `bits` of `base`
91 base: IpAddr,
92 bits: u8,
93 },
94}
95
96impl TrustedPeer {
97 /// One configuration entry, written either `addr` or `addr/bits`.
98 pub fn parse(entry: &str) -> Outcome<Self> {
99 let entry = entry.trim();
100 match entry.rsplit_once('/') {
101 Some((addr, bits)) => {
102 let base = res!(addr.parse::<IpAddr>().map_err(|e| err!(e,
103 "Trusted proxy: '{}' is not an IP address.", addr;
104 Invalid, Input, Decode)));
105 let bits = res!(bits.parse::<u8>().map_err(|e| err!(e,
106 "Trusted proxy: '{}' is not a prefix length.", bits;
107 Invalid, Input, Decode)));
108 let max = match base {
109 IpAddr::V4(_) => 32,
110 IpAddr::V6(_) => 128,
111 };
112 if bits > max {
113 return Err(err!(
114 "Trusted proxy: prefix length {} exceeds the {} bits of '{}'.",
115 bits, max, base;
116 Invalid, Input, TooBig));
117 }
118 Ok(Self::Prefix { base, bits })
119 }
120 None => {
121 let addr = res!(entry.parse::<IpAddr>().map_err(|e| err!(e,
122 "Trusted proxy: '{}' is neither an IP address nor a prefix.", entry;
123 Invalid, Input, Decode)));
124 Ok(Self::Addr(addr))
125 }
126 }
127 }
128
129 /// Does this entry cover the given address?
130 pub fn covers(&self, addr: &IpAddr) -> bool {
131 match self {
132 Self::Addr(held) => held == addr,
133 Self::Prefix { base, bits } => prefix_covers(base, *bits, addr),
134 }
135 }
136}
137
138/// Do `base` and `addr` share their leading `bits`?
139///
140/// A v4 prefix never covers a v6 address and vice versa: an operator who writes both means both,
141/// and silently widening one family into the other is how a prefix ends up covering more than it
142/// says.
143fn prefix_covers(
144 base: &IpAddr,
145 bits: u8,
146 addr: &IpAddr,
147)
148 -> bool
149{
150 let (base_bytes, addr_bytes): (Vec<u8>, Vec<u8>) = match (base, addr) {
151 (IpAddr::V4(b), IpAddr::V4(a)) => (b.octets().to_vec(), a.octets().to_vec()),
152 (IpAddr::V6(b), IpAddr::V6(a)) => (b.octets().to_vec(), a.octets().to_vec()),
153 _ => return false,
154 };
155 let whole = (bits / 8) as usize;
156 let spare = bits % 8;
157 if base_bytes[..whole] != addr_bytes[..whole] {
158 return false;
159 }
160 if spare == 0 {
161 return true;
162 }
163 let mask = 0xffu8 << (8 - spare);
164 (base_bytes[whole] & mask) == (addr_bytes[whole] & mask)
165}
166
167/// Which immediate peers are believed when they speak the forwarding headers.
168///
169/// Empty means nobody, which means the caller's copies are always stripped. That is the default and
170/// it is correct for a host that faces the public directly: nothing in front of the proxy means
171/// nothing in front of the proxy is entitled to name the client.
172///
173/// It stops being correct the day something does sit in front. Stripping unconditionally would then
174/// discard the real client address rather than preserve it, replacing every client with the CDN's
175/// egress -- a security fix turned into a quieter bug. Hence the policy rather than a constant.
176#[derive(Clone, Debug, Default, Eq, PartialEq)]
177pub struct ForwardedPolicy {
178 trusted: Vec<TrustedPeer>, // their forwarding headers are preserved
179}
180
181impl ForwardedPolicy {
182 /// A policy that trusts nobody, so every caller's forwarding headers are stripped.
183 pub fn none() -> Self {
184 Self { trusted: Vec::new() }
185 }
186
187 /// Build a policy from configuration entries, each a bare address or a prefix.
188 ///
189 /// An entry that will not parse is an error rather than an entry quietly skipped: a skipped
190 /// entry leaves an allow-list that looks populated and trusts nobody, or an operator who
191 /// believes their CDN is named here when it is not.
192 pub fn new(entries: &[String]) -> Outcome<Self> {
193 let mut trusted = Vec::with_capacity(entries.len());
194 for entry in entries {
195 trusted.push(res!(TrustedPeer::parse(entry)));
196 }
197 Ok(Self { trusted })
198 }
199
200 /// Does this policy name anybody at all?
201 pub fn is_empty(&self) -> bool {
202 self.trusted.is_empty()
203 }
204
205 /// Is the immediate peer one whose forwarding headers are believed?
206 ///
207 /// A v4 address arriving on a dual-stack listener is reported as the v4-mapped v6 address
208 /// `::ffff:a.b.c.d`, which would never match a v4 entry written the obvious way, so it is
209 /// unmapped before the comparison.
210 pub fn trusts(&self, peer: &SocketAddr) -> bool {
211 let addr = match peer.ip() {
212 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
213 Some(v4) => IpAddr::V4(v4),
214 None => IpAddr::V6(v6),
215 },
216 other => other,
217 };
218 self.trusted.iter().any(|held| held.covers(&addr))
219 }
220}
221
222/// Is this a `Host` value safe to repeat inside a header a hop writes?
223///
224/// The caller chose it, so it is quoted into `Forwarded` and repeated in `X-Forwarded-Host`.
225/// Anything outside the host grammar is dropped rather than escaped: a value that cannot be a host
226/// is not one worth passing on, and dropping it is the only outcome with no way to be wrong.
227fn is_safe_host(value: &str) -> bool {
228 !value.is_empty()
229 && value.len() <= 255
230 && value.chars().all(|c|
231 c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | ':' | '[' | ']' | '_'))
232}
233
234/// Copy the caller's headers into `req`, then append this hop's own forwarding headers.
235///
236/// The caller's `Host`, `Connection`, `Content-Length` and `Transfer-Encoding` are never copied --
237/// this hop writes its own. The four forwarding headers are copied only when `policy` trusts
238/// `peer`; otherwise they are dropped, so the values this function appends are the only ones the
239/// upstream sees.
240///
241/// This hop's own values go last in every case, which is what makes "read the last value" a correct
242/// instruction for an upstream whatever the policy says. The reader that does it is
243/// [`HeaderFields::get_last`](crate::http::fields::HeaderFields::get_last).
244///
245/// The scheme written is `https`: these builders serve a TLS listener, which is the only listener
246/// entitled to say so.
247pub fn write_forwarded_headers(
248 req: &mut String,
249 request: &HttpMessage,
250 peer: &SocketAddr,
251 policy: &ForwardedPolicy,
252) {
253 let trusted = policy.trusts(peer);
254 let mut caller_host: Option<String> = None;
255
256 for (name, values) in request.header.fields.iter() {
257 let name_str = fmt!("{}", name);
258 if is_managed_header(&name_str) {
259 if name_str.eq_ignore_ascii_case("host") {
260 caller_host = values.first().map(|value| fmt!("{}", value));
261 }
262 continue;
263 }
264 if !trusted && is_forwarded_header(&name_str) {
265 continue;
266 }
267 for value in values {
268 req.push_str(&fmt!("{}: {}\r\n", name_str, value));
269 }
270 }
271
272 // This hop's own account of the request, appended after anything preserved above.
273 req.push_str(&fmt!("X-Forwarded-For: {}\r\n", peer));
274 req.push_str("X-Forwarded-Proto: https\r\n");
275 let host = match caller_host {
276 Some(ref value) if is_safe_host(value) => Some(value.clone()),
277 _ => None,
278 };
279 if let Some(ref value) = host {
280 req.push_str(&fmt!("X-Forwarded-Host: {}\r\n", value));
281 }
282 // RFC 7239 §4. The `for` node identifier carries a port and so must be a quoted string, and a
283 // v6 address must be bracketed inside it -- which is exactly how `SocketAddr` prints.
284 match host {
285 Some(ref value) => req.push_str(&fmt!(
286 "Forwarded: for=\"{}\";proto=https;host=\"{}\"\r\n", peer, value)),
287 None => req.push_str(&fmt!(
288 "Forwarded: for=\"{}\";proto=https\r\n", peer)),
289 }
290}
291
292/// Build the whole request head a hop sends to an HTTP proxy upstream.
293///
294/// The returned string is the bytes written to the upstream socket, headers and terminating blank
295/// line included. `Connection: close` makes the response termination unambiguous, and
296/// `Content-Length` describes the body this hop is about to write rather than the one the caller
297/// claimed.
298pub fn build_proxy_request_head(
299 method: &str,
300 upstream_path: &str,
301 upstream_host: &str,
302 request: &HttpMessage,
303 peer: &SocketAddr,
304 policy: &ForwardedPolicy,
305 body_len: usize,
306)
307 -> String
308{
309 let mut req = String::with_capacity(512 + body_len);
310 req.push_str(&fmt!("{} {} HTTP/1.1\r\n", method, upstream_path));
311 req.push_str(&fmt!("Host: {}\r\n", upstream_host));
312 write_forwarded_headers(&mut req, request, peer, policy);
313 req.push_str("Connection: close\r\n");
314 req.push_str(&fmt!("Content-Length: {}\r\n", body_len));
315 req.push_str("\r\n");
316 req
317}
318
319/// Build the whole upgrade request head a hop sends to a WebSocket upstream.
320///
321/// There is no body, so no `Content-Length`; `Connection: Upgrade` replaces the caller's, which is
322/// hop-by-hop and never copied.
323pub fn build_upgrade_request_head(
324 upstream_path: &str,
325 upstream_host: &str,
326 request: &HttpMessage,
327 peer: &SocketAddr,
328 policy: &ForwardedPolicy,
329)
330 -> String
331{
332 let mut req = String::with_capacity(512);
333 req.push_str(&fmt!("GET {} HTTP/1.1\r\n", upstream_path));
334 req.push_str(&fmt!("Host: {}\r\n", upstream_host));
335 write_forwarded_headers(&mut req, request, peer, policy);
336 req.push_str("Connection: Upgrade\r\n");
337 req.push_str("\r\n");
338 req
339}
340
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::http::{
346 fields::HeaderField,
347 header::HttpHeader,
348 };
349
350 /// Header names are one name whatever their case.
351 ///
352 /// The wire parser lowercases every name it reads, so a case-sensitive comparison against the
353 /// lowercase forms would pass every test driven from bytes and still be wrong for a message
354 /// built in code. This checks the predicate itself, which is the only place the property lives.
355 #[test]
356 fn test_forwarded_header_names_are_case_insensitive_00() {
357 for name in [
358 "x-forwarded-for",
359 "X-Forwarded-For",
360 "X-FORWARDED-FOR",
361 "x-forwarded-proto",
362 "X-Forwarded-Proto",
363 "X-FORWARDED-PROTO",
364 "x-forwarded-host",
365 "X-Forwarded-Host",
366 "forwarded",
367 "Forwarded",
368 "FORWARDED",
369 ] {
370 assert!(is_forwarded_header(name),
371 "'{}' is a forwarding header whatever its case", name);
372 }
373 assert!(!is_forwarded_header("x-forwarded-for-real"),
374 "the match is the whole name, not a prefix");
375 assert!(!is_forwarded_header("cookie"));
376
377 for name in ["host", "Host", "HOST", "connection", "Connection",
378 "content-length", "Content-Length", "transfer-encoding", "Transfer-Encoding"] {
379 assert!(is_managed_header(name),
380 "'{}' is a managed header whatever its case", name);
381 }
382 assert!(!is_managed_header("x-forwarded-for"));
383 }
384
385 /// A bare address covers itself and nothing else.
386 #[test]
387 fn test_trusted_peer_bare_address_00() -> Outcome<()> {
388 let peer = res!(TrustedPeer::parse("198.51.100.7"));
389 assert!(peer.covers(&res!("198.51.100.7".parse::<IpAddr>(), Test)));
390 assert!(!peer.covers(&res!("198.51.100.8".parse::<IpAddr>(), Test)));
391 Ok(())
392 }
393
394 /// A prefix covers its range, stops at its edges, and does not cross address families.
395 #[test]
396 fn test_trusted_peer_prefix_00() -> Outcome<()> {
397 let peer = res!(TrustedPeer::parse("198.51.100.0/24"));
398 assert!(peer.covers(&res!("198.51.100.0".parse::<IpAddr>(), Test)));
399 assert!(peer.covers(&res!("198.51.100.255".parse::<IpAddr>(), Test)));
400 assert!(!peer.covers(&res!("198.51.101.0".parse::<IpAddr>(), Test)));
401
402 // A prefix that does not end on a byte boundary, which is where an implementation that
403 // only compares whole bytes goes wrong.
404 let peer = res!(TrustedPeer::parse("10.1.0.0/20"));
405 assert!(peer.covers(&res!("10.1.0.1".parse::<IpAddr>(), Test)));
406 assert!(peer.covers(&res!("10.1.15.255".parse::<IpAddr>(), Test)));
407 assert!(!peer.covers(&res!("10.1.16.0".parse::<IpAddr>(), Test)),
408 "10.1.16.0 is outside a /20 based at 10.1.0.0");
409
410 // Families do not mix.
411 let peer = res!(TrustedPeer::parse("2001:db8::/32"));
412 assert!(peer.covers(&res!("2001:db8::1".parse::<IpAddr>(), Test)));
413 assert!(!peer.covers(&res!("2001:db9::1".parse::<IpAddr>(), Test)));
414 assert!(!peer.covers(&res!("10.0.0.1".parse::<IpAddr>(), Test)));
415
416 // Nonsense is a configuration error, not a peer that quietly trusts nobody.
417 assert!(TrustedPeer::parse("not-an-address").is_err());
418 assert!(TrustedPeer::parse("10.0.0.0/33").is_err());
419 assert!(TrustedPeer::parse("10.0.0.0/x").is_err());
420 Ok(())
421 }
422
423 /// The default policy trusts nobody, and a v4 peer arriving v4-mapped still matches.
424 #[test]
425 fn test_forwarded_policy_trusts_00() -> Outcome<()> {
426 let none = ForwardedPolicy::none();
427 assert!(none.is_empty());
428 assert!(!none.trusts(&res!("203.0.113.7:4000".parse::<SocketAddr>(), Test)));
429
430 let policy = res!(ForwardedPolicy::new(&[fmt!("198.51.100.0/24")]));
431 assert!(policy.trusts(&res!("198.51.100.9:4000".parse::<SocketAddr>(), Test)));
432 assert!(!policy.trusts(&res!("203.0.113.7:4000".parse::<SocketAddr>(), Test)));
433 assert!(policy.trusts(&res!("[::ffff:198.51.100.9]:4000".parse::<SocketAddr>(), Test)),
434 "a dual-stack listener reports a v4 peer as v4-mapped v6");
435 Ok(())
436 }
437
438 /// An entry that will not parse is refused, not skipped.
439 ///
440 /// A policy built from a list with one bad entry and the rest good would otherwise trust the
441 /// rest, which is an allow-list that reads as populated while missing whatever the typo was.
442 #[test]
443 fn test_forwarded_policy_refuses_a_bad_entry_00() -> Outcome<()> {
444 assert!(ForwardedPolicy::new(&[fmt!("198.51.100.0/24"), fmt!("nonsense")]).is_err());
445 let policy = res!(ForwardedPolicy::new(&[fmt!("198.51.100.0/24")]));
446 assert!(policy.trusts(&res!("198.51.100.1:80".parse::<SocketAddr>(), Test)));
447 Ok(())
448 }
449
450 /// A caller's `multipart/form-data` reaches the upstream still naming its top
451 /// level.
452 ///
453 /// The line a hop writes comes from the parsed field, and the multipart arm of
454 /// `ContentTypeValue` wrote the subtype alone, so `multipart/form-data;
455 /// boundary=x` left the hop as `form-data; boundary=x`. The upstream's own
456 /// parser then refused it -- "Invalid Media type 'form-data', '/' character not
457 /// found" -- and dropped the connection without answering. Seen six times on
458 /// the live forge, from a scanner posting a form to `/`.
459 #[test]
460 fn test_a_multipart_content_type_survives_a_hop_00() -> Outcome<()> {
461 let wire = fmt!(
462 "POST / HTTP/1.1\r\n\
463 Host: forge.example\r\n\
464 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAbC\r\n\
465 Content-Length: 0\r\n");
466 let request = HttpMessage {
467 header: res!(HttpHeader::parse(wire, Some(true))),
468 ..Default::default()
469 };
470 let head = build_proxy_request_head(
471 "POST",
472 "/",
473 "127.0.0.1",
474 &request,
475 &res!("127.0.0.1:48742".parse::<SocketAddr>(), Test),
476 &ForwardedPolicy::none(),
477 0,
478 );
479 assert!(
480 head.to_lowercase().contains("content-type: multipart/form-data; boundary="),
481 "the hop wrote:\n{}", head,
482 );
483 // The boundary's CASE is a second property and a second defect: the
484 // parameter value used to be lowercased with everything else, and a
485 // multipart boundary is case sensitive (RFC 2046 s5.1.1), so the
486 // upstream would have looked for a delimiter the body does not contain.
487 assert!(
488 head.contains("boundary=----WebKitFormBoundaryAbC\r\n"),
489 "the boundary lost its case:\n{}", head,
490 );
491 // And the upstream can read back what the hop wrote, which is the failure
492 // as the forge met it.
493 for line in head.lines() {
494 if line.to_lowercase().starts_with("content-type:") {
495 res!(HeaderField::new(line, None));
496 }
497 }
498 Ok(())
499 }
500}