oxedyne/fe2o3/fe2o3_steel/tests/forwarded_headers.rs
14.1 KiB, 5 runs
created by r1870400018:21896, 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 | //! That Steel actually uses the forwarding policy, on the path a request really takes. |
| 2 | //! |
| 3 | //! The policy itself, and the two request-head builders, live in `fe2o3_net::http::fwd` and are |
| 4 | //! tested there against the bytes they produce. What is left here is the question that crate cannot |
| 5 | //! answer: whether *Steel* reaches for them. A helper that behaves perfectly and is never called |
| 6 | //! strips nothing. |
| 7 | //! |
| 8 | //! So the WebSocket relay is driven end to end against a real socket -- Steel's `tunnel_upgrade`, |
| 9 | //! Steel's argument order, a capturing upstream recording what arrived -- and the configuration |
| 10 | //! that feeds the policy is loaded through `ServerConfig` the way a deployment loads it. |
| 11 | //! |
| 12 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 13 | //! Anthropic Claude |
| 14 | |
| 15 | use oxedyne_fe2o3_core::prelude::*; |
| 16 | use oxedyne_fe2o3_jdat::prelude::*; |
| 17 | use oxedyne_fe2o3_net::http::{ |
| 18 | fwd::ForwardedPolicy, |
| 19 | msg::HttpMessage, |
| 20 | }; |
| 21 | use oxedyne_fe2o3_steel::srv::{ |
| 22 | cfg::ServerConfig, |
| 23 | wsproxy::tunnel_upgrade, |
| 24 | }; |
| 25 | |
| 26 | use std::{ |
| 27 | net::SocketAddr, |
| 28 | pin::Pin, |
| 29 | sync::{ |
| 30 | Arc, |
| 31 | Mutex, |
| 32 | }, |
| 33 | time::Duration, |
| 34 | }; |
| 35 | |
| 36 | use tokio::{ |
| 37 | io::{ |
| 38 | AsyncReadExt, |
| 39 | AsyncWriteExt, |
| 40 | }, |
| 41 | net::TcpListener, |
| 42 | }; |
| 43 | |
| 44 | |
| 45 | /// Parse raw request bytes into an `HttpMessage` through the real wire parser. |
| 46 | /// |
| 47 | /// Building the message rather than parsing it would skip the name normalisation the parser does, |
| 48 | /// and the point of these tests is what happens to bytes that arrived from outside. |
| 49 | async fn parse_request(raw: &str) -> Outcome<HttpMessage> { |
| 50 | let (mut near, mut far) = tokio::io::duplex(8192); |
| 51 | let bytes = raw.as_bytes().to_vec(); |
| 52 | tokio::spawn(async move { |
| 53 | let _ = far.write_all(&bytes).await; |
| 54 | let _ = far.flush().await; |
| 55 | }); |
| 56 | let read = HttpMessage::read::<1024, 1024, _>( |
| 57 | Pin::new(&mut near), |
| 58 | &Vec::new(), |
| 59 | Some(true), |
| 60 | None, |
| 61 | ).await; |
| 62 | match res!(read) { |
| 63 | (Some(msg), _) => Ok(msg), |
| 64 | (None, _) => Err(err!("The test request did not parse."; Test, Invalid, Input)), |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// Every value of a header, in the order it appears in a raw request head. |
| 69 | /// |
| 70 | /// The comparison is case-insensitive on the name, so a head that names the field differently from |
| 71 | /// the test still has its values counted -- otherwise a strip that merely changed the case of a |
| 72 | /// forgery would read as a strip. |
| 73 | fn values_of(head: &str, name: &str) -> Vec<String> { |
| 74 | let mut out = Vec::new(); |
| 75 | for line in head.split("\r\n") { |
| 76 | if let Some((held, value)) = line.split_once(':') { |
| 77 | if held.trim().eq_ignore_ascii_case(name) { |
| 78 | out.push(value.trim().to_string()); |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | out |
| 83 | } |
| 84 | |
| 85 | /// Run one upgrade through the relay and return the request head the upstream received. |
| 86 | /// |
| 87 | /// The upstream is a bare socket that records what arrived and answers a `101`, so what is asserted |
| 88 | /// on is the wire, not a parse of it. |
| 89 | async fn relay_and_capture( |
| 90 | raw_request: &str, |
| 91 | peer: &str, |
| 92 | policy: ForwardedPolicy, |
| 93 | ) |
| 94 | -> Outcome<String> |
| 95 | { |
| 96 | let listener = match TcpListener::bind("127.0.0.1:0").await { |
| 97 | Ok(l) => l, |
| 98 | Err(e) => return Err(err!(e, "Could not bind the capturing upstream."; IO, Network, Init)), |
| 99 | }; |
| 100 | let addr = res!(listener.local_addr(), IO, Network); |
| 101 | let seen = Arc::new(Mutex::new(String::new())); |
| 102 | let seen_far = seen.clone(); |
| 103 | tokio::spawn(async move { |
| 104 | let mut stream = match listener.accept().await { |
| 105 | Ok((s, _)) => s, |
| 106 | Err(_) => return, |
| 107 | }; |
| 108 | let mut accum: Vec<u8> = Vec::new(); |
| 109 | let mut buf = [0u8; 1024]; |
| 110 | loop { |
| 111 | let n = match stream.read(&mut buf).await { |
| 112 | Ok(0) => break, |
| 113 | Ok(n) => n, |
| 114 | Err(_) => return, |
| 115 | }; |
| 116 | accum.extend_from_slice(&buf[..n]); |
| 117 | if accum.windows(4).any(|w| w == b"\r\n\r\n") { |
| 118 | break; |
| 119 | } |
| 120 | if accum.len() > 65536 { |
| 121 | break; |
| 122 | } |
| 123 | } |
| 124 | if let Ok(mut guard) = seen_far.lock() { |
| 125 | *guard = String::from_utf8_lossy(&accum).to_string(); |
| 126 | } |
| 127 | // Any 101 will do: nothing here tests the handshake. |
| 128 | let _ = stream.write_all( |
| 129 | b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n").await; |
| 130 | let _ = stream.shutdown().await; |
| 131 | }); |
| 132 | |
| 133 | let request = res!(parse_request(raw_request).await); |
| 134 | let (browser, mut steel) = tokio::io::duplex(8192); |
| 135 | let src: SocketAddr = res!(peer.parse::<SocketAddr>(), Test); |
| 136 | let relay = tokio::spawn(async move { |
| 137 | tunnel_upgrade( |
| 138 | &mut steel, &request, "127.0.0.1", addr.port(), "/ws", src, &policy, "Test|fwd", |
| 139 | ).await |
| 140 | }); |
| 141 | // The upstream drops the connection after its 101, which ends the relay. |
| 142 | let _ = tokio::time::timeout(Duration::from_secs(5), relay).await; |
| 143 | drop(browser); |
| 144 | |
| 145 | let head = match seen.lock() { |
| 146 | Ok(g) => g.clone(), |
| 147 | Err(_) => return Err(err!("The upstream's record is poisoned."; Test, Poisoned)), |
| 148 | }; |
| 149 | if head.is_empty() { |
| 150 | return Err(err!("The upstream received nothing."; Test, Missing)); |
| 151 | } |
| 152 | Ok(head) |
| 153 | } |
| 154 | |
| 155 | /// A forged `X-Forwarded-For` never reaches the upstream when no peer is trusted. |
| 156 | /// |
| 157 | /// The forgery is sent in three casings and as a chain, because a caller chooses all of that. What |
| 158 | /// the upstream must see is one value, and it must be the address Steel accepted the connection |
| 159 | /// from. |
| 160 | #[tokio::test] |
| 161 | async fn test_forged_x_forwarded_for_is_stripped_00() -> Outcome<()> { |
| 162 | let raw = "GET /ws HTTP/1.1\r\n\ |
| 163 | Host: app.example\r\n\ |
| 164 | Upgrade: websocket\r\n\ |
| 165 | X-Forwarded-For: 9.9.9.9\r\n\ |
| 166 | x-forwarded-for: 8.8.8.8, 7.7.7.7\r\n\ |
| 167 | X-FORWARDED-FOR: 6.6.6.6\r\n\ |
| 168 | \r\n"; |
| 169 | let head = res!(relay_and_capture(raw, "203.0.113.7:51000", ForwardedPolicy::none()).await); |
| 170 | |
| 171 | let seen = values_of(&head, "x-forwarded-for"); |
| 172 | assert_eq!(seen, vec![fmt!("203.0.113.7:51000")], |
| 173 | "the upstream must see one X-Forwarded-For, this hop's; got {:?} in head:\n{}", |
| 174 | seen, head); |
| 175 | for forged in ["9.9.9.9", "8.8.8.8", "7.7.7.7", "6.6.6.6"] { |
| 176 | assert!(!head.contains(forged), |
| 177 | "the forged address '{}' reached the upstream:\n{}", forged, head); |
| 178 | } |
| 179 | Ok(()) |
| 180 | } |
| 181 | |
| 182 | /// A forged `X-Forwarded-Proto` never reaches the upstream when no peer is trusted. |
| 183 | /// |
| 184 | /// An upstream that reads the first value and finds `http` believes a TLS request arrived in |
| 185 | /// plaintext. One that redirects plaintext to HTTPS on that basis redirects a request that is |
| 186 | /// already HTTPS, and the client comes back to the same answer. |
| 187 | #[tokio::test] |
| 188 | async fn test_forged_x_forwarded_proto_is_stripped_00() -> Outcome<()> { |
| 189 | let raw = "GET /ws HTTP/1.1\r\n\ |
| 190 | Host: app.example\r\n\ |
| 191 | Upgrade: websocket\r\n\ |
| 192 | X-Forwarded-Proto: http\r\n\ |
| 193 | \r\n"; |
| 194 | let head = res!(relay_and_capture(raw, "203.0.113.7:51001", ForwardedPolicy::none()).await); |
| 195 | |
| 196 | let seen = values_of(&head, "x-forwarded-proto"); |
| 197 | assert_eq!(seen, vec![fmt!("https")], |
| 198 | "the upstream must see one X-Forwarded-Proto, this hop's; got {:?} in head:\n{}", |
| 199 | seen, head); |
| 200 | Ok(()) |
| 201 | } |
| 202 | |
| 203 | /// A forged `X-Forwarded-Host` and a forged RFC 7239 `Forwarded` are stripped too, and this hop's |
| 204 | /// own account of both is appended. |
| 205 | /// |
| 206 | /// `X-Forwarded-Host` is the worst of the set left alone: Steel replaces `Host` with the upstream's |
| 207 | /// own, so without this the upstream has no truthful source for the host the client addressed -- |
| 208 | /// only the caller's, which nothing checks. |
| 209 | #[tokio::test] |
| 210 | async fn test_forged_host_and_forwarded_are_stripped_00() -> Outcome<()> { |
| 211 | let raw = "GET /ws HTTP/1.1\r\n\ |
| 212 | Host: app.example\r\n\ |
| 213 | Upgrade: websocket\r\n\ |
| 214 | X-Forwarded-Host: evil.example\r\n\ |
| 215 | Forwarded: for=9.9.9.9;proto=http;host=evil.example\r\n\ |
| 216 | \r\n"; |
| 217 | let head = res!(relay_and_capture(raw, "203.0.113.7:51002", ForwardedPolicy::none()).await); |
| 218 | |
| 219 | let seen = values_of(&head, "x-forwarded-host"); |
| 220 | assert_eq!(seen, vec![fmt!("app.example")], |
| 221 | "the upstream must see the host the client addressed, once; got {:?} in head:\n{}", |
| 222 | seen, head); |
| 223 | let seen = values_of(&head, "forwarded"); |
| 224 | assert_eq!(seen, vec![fmt!("for=\"203.0.113.7:51002\";proto=https;host=\"app.example\"")], |
| 225 | "the upstream must see one Forwarded, this hop's; got {:?} in head:\n{}", seen, head); |
| 226 | assert!(!head.contains("evil.example"), |
| 227 | "the forged host reached the upstream:\n{}", head); |
| 228 | Ok(()) |
| 229 | } |
| 230 | |
| 231 | /// With the peer trusted, the caller's chain is preserved and this hop's value appended after it. |
| 232 | /// |
| 233 | /// This is what a content delivery network needs: strip unconditionally and the real client address |
| 234 | /// is discarded rather than preserved, which is the same bug wearing a safer face. Steel's own |
| 235 | /// value is still last, so a downstream reader taking the last value is right under either |
| 236 | /// configuration. |
| 237 | #[tokio::test] |
| 238 | async fn test_trusted_peer_chain_is_preserved_00() -> Outcome<()> { |
| 239 | let raw = "GET /ws HTTP/1.1\r\n\ |
| 240 | Host: app.example\r\n\ |
| 241 | Upgrade: websocket\r\n\ |
| 242 | X-Forwarded-For: 198.51.100.34\r\n\ |
| 243 | X-Forwarded-Proto: https\r\n\ |
| 244 | \r\n"; |
| 245 | let policy = res!(ForwardedPolicy::new(&[fmt!("203.0.113.0/24")])); |
| 246 | let head = res!(relay_and_capture(raw, "203.0.113.7:51003", policy).await); |
| 247 | |
| 248 | let seen = values_of(&head, "x-forwarded-for"); |
| 249 | assert_eq!(seen, vec![fmt!("198.51.100.34"), fmt!("203.0.113.7:51003")], |
| 250 | "a trusted peer's chain is kept and this hop appended to it; got {:?} in head:\n{}", |
| 251 | seen, head); |
| 252 | let seen = values_of(&head, "x-forwarded-proto"); |
| 253 | assert_eq!(seen, vec![fmt!("https"), fmt!("https")], |
| 254 | "the proto chain is kept the same way; got {:?} in head:\n{}", seen, head); |
| 255 | Ok(()) |
| 256 | } |
| 257 | |
| 258 | /// An untrusted peer that sends nothing still has this hop's account appended. |
| 259 | /// |
| 260 | /// Stripping is not the whole job. If the strip ran and the append did not, the upstream would fall |
| 261 | /// back to its socket peer -- which behind Steel is loopback, and loopback is the address a trusted |
| 262 | /// gateway path is written for. |
| 263 | #[tokio::test] |
| 264 | async fn test_this_hop_names_itself_when_the_caller_said_nothing_00() -> Outcome<()> { |
| 265 | let raw = "GET /ws HTTP/1.1\r\n\ |
| 266 | Host: app.example\r\n\ |
| 267 | Upgrade: websocket\r\n\ |
| 268 | \r\n"; |
| 269 | let head = res!(relay_and_capture(raw, "198.51.100.200:51004", ForwardedPolicy::none()).await); |
| 270 | |
| 271 | assert_eq!(values_of(&head, "x-forwarded-for"), vec![fmt!("198.51.100.200:51004")]); |
| 272 | assert_eq!(values_of(&head, "x-forwarded-proto"), vec![fmt!("https")]); |
| 273 | assert_eq!(values_of(&head, "x-forwarded-host"), vec![fmt!("app.example")]); |
| 274 | assert_eq!(values_of(&head, "forwarded"), |
| 275 | vec![fmt!("for=\"198.51.100.200:51004\";proto=https;host=\"app.example\"")]); |
| 276 | Ok(()) |
| 277 | } |
| 278 | |
| 279 | /// The caller's own headers still reach the upstream. A strip that took the rest with it would be a |
| 280 | /// different outage. |
| 281 | #[tokio::test] |
| 282 | async fn test_the_callers_other_headers_still_ride_through_00() -> Outcome<()> { |
| 283 | let raw = "GET /ws HTTP/1.1\r\n\ |
| 284 | Host: app.example\r\n\ |
| 285 | Upgrade: websocket\r\n\ |
| 286 | Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ |
| 287 | Sec-WebSocket-Version: 13\r\n\ |
| 288 | Cookie: sid=abc123\r\n\ |
| 289 | X-Forwarded-For: 9.9.9.9\r\n\ |
| 290 | \r\n"; |
| 291 | let head = res!(relay_and_capture(raw, "203.0.113.7:51005", ForwardedPolicy::none()).await); |
| 292 | |
| 293 | assert_eq!(values_of(&head, "sec-websocket-key"), vec![fmt!("dGhlIHNhbXBsZSBub25jZQ==")]); |
| 294 | assert_eq!(values_of(&head, "sec-websocket-version"), vec![fmt!("13")]); |
| 295 | assert_eq!(values_of(&head, "cookie"), vec![fmt!("sid=abc123")]); |
| 296 | assert_eq!(values_of(&head, "host"), vec![fmt!("127.0.0.1")], |
| 297 | "the Host belongs to this hop, not to the caller's original"); |
| 298 | Ok(()) |
| 299 | } |
| 300 | |
| 301 | /// A configuration written before `trusted_proxies` existed still loads, and trusts nobody. |
| 302 | /// |
| 303 | /// A field added without `#[optional]` is required, and a required field added to a struct backing |
| 304 | /// two live production configurations is an outage rather than a feature. |
| 305 | #[test] |
| 306 | fn test_a_config_without_trusted_proxies_still_loads_00() -> Outcome<()> { |
| 307 | let mut m = DaticleMap::new(); |
| 308 | m.insert(dat!("tls_dir_rel"), dat!("./tls")); |
| 309 | m.insert(dat!("log_level"), dat!("debug")); |
| 310 | m.insert(dat!("num_server_bots"), Dat::U16(1)); |
| 311 | m.insert(dat!("server_address"), dat!("0.0.0.0")); |
| 312 | m.insert(dat!("server_port_tcp"), Dat::U16(8443)); |
| 313 | m.insert(dat!("server_port_tcp_plaintext"), Dat::U16(0)); |
| 314 | m.insert(dat!("hsts_max_age_secs"), Dat::U32(0)); |
| 315 | m.insert(dat!("session_expiry_default_secs"), Dat::U32(604_800)); |
| 316 | m.insert(dat!("ws_ping_interval_secs"), Dat::U8(30)); |
| 317 | m.insert(dat!("server_max_errors_allowed"), Dat::U8(30)); |
| 318 | m.insert(dat!("allow_anonymous_sessions"), Dat::Bool(true)); |
| 319 | m.insert(dat!("vhosts"), Dat::List(Vec::new())); |
| 320 | m.insert(dat!("acme"), Dat::Map(DaticleMap::new())); |
| 321 | m.insert(dat!("mail"), Dat::Map(DaticleMap::new())); |
| 322 | |
| 323 | let cfg = res!(ServerConfig::from_datmap(m)); |
| 324 | assert!(cfg.trusted_proxies.is_empty(), |
| 325 | "a config that names no trusted proxy trusts none"); |
| 326 | let policy = res!(cfg.get_forwarded_policy()); |
| 327 | assert!(policy.is_empty()); |
| 328 | assert!(!policy.trusts(&res!("127.0.0.1:4000".parse::<SocketAddr>(), Test)), |
| 329 | "loopback is not trusted by default -- that exemption is what the hole was written into"); |
| 330 | Ok(()) |
| 331 | } |
| 332 | |
| 333 | /// A mistyped trusted proxy is a start-up failure, not an allow-list that silently trusts nobody. |
| 334 | #[test] |
| 335 | fn test_a_mistyped_trusted_proxy_is_refused_00() -> Outcome<()> { |
| 336 | let mut cfg = ServerConfig::default(); |
| 337 | cfg.trusted_proxies = vec![fmt!("198.51.100.0/24"), fmt!("not-an-address")]; |
| 338 | assert!(cfg.get_forwarded_policy().is_err(), |
| 339 | "an entry that cannot be parsed must be reported, not skipped"); |
| 340 | |
| 341 | cfg.trusted_proxies = vec![fmt!("198.51.100.0/24")]; |
| 342 | let policy = res!(cfg.get_forwarded_policy()); |
| 343 | assert!(policy.trusts(&res!("198.51.100.1:80".parse::<SocketAddr>(), Test))); |
| 344 | Ok(()) |
| 345 | } |