Oregami
Repositories/oxedyne/fe2o3

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
15use oxedyne_fe2o3_core::prelude::*;
16use oxedyne_fe2o3_jdat::prelude::*;
17use oxedyne_fe2o3_net::http::{
18 fwd::ForwardedPolicy,
19 msg::HttpMessage,
20};
21use oxedyne_fe2o3_steel::srv::{
22 cfg::ServerConfig,
23 wsproxy::tunnel_upgrade,
24};
25
26use std::{
27 net::SocketAddr,
28 pin::Pin,
29 sync::{
30 Arc,
31 Mutex,
32 },
33 time::Duration,
34};
35
36use 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.
49async 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.
73fn 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.
89async 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]
161async 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]
188async 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]
210async 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]
238async 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]
264async 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]
282async 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]
306fn 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]
335fn 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}