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 | |
| 34 | use crate::http::msg::HttpMessage; |
| 35 | |
| 36 | use oxedyne_fe2o3_core::prelude::*; |
| 37 | |
| 38 | use 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. |
| 47 | pub 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. |
| 60 | pub 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. |
| 71 | pub 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`]. |
| 78 | pub 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)] |
| 88 | pub 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 | |
| 96 | impl 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. |
| 143 | fn 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)] |
| 177 | pub struct ForwardedPolicy { |
| 178 | trusted: Vec<TrustedPeer>, // their forwarding headers are preserved |
| 179 | } |
| 180 | |
| 181 | impl 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. |
| 227 | fn 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. |
| 247 | pub 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. |
| 298 | pub 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. |
| 323 | pub 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)] |
| 343 | mod 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 | } |