Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/tests/forwarded.rs

12.6 KiB, 14 runs

created by r1870400018:21872, 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#![cfg(feature = "async")]
2//! What a caller may and may not tell an upstream about the hop it took.
3//!
4//! A reverse proxy appends `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host` and RFC 7239
5//! `Forwarded` describing the connection it actually accepted. If the caller's own copies ride
6//! through as well, the upstream receives two values -- the caller's first, the hop's second -- and
7//! `HeaderFields::get_one` returns `list[0]`. An upstream doing the obvious thing therefore reads
8//! whatever the caller invented.
9//!
10//! These tests assert on the bytes the builders in `http::fwd` produce, and then on what the wire
11//! parser makes of those bytes. Asserting on the string alone would prove what was written;
12//! asserting through the parser proves what a reader of it actually gets, which is the property an
13//! upstream depends on.
14//!
15//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
16//! Anthropic Claude
17
18use oxedyne_fe2o3_core::prelude::*;
19use oxedyne_fe2o3_net::http::{
20 fields::HeaderName,
21 fwd::{
22 build_proxy_request_head,
23 build_upgrade_request_head,
24 ForwardedPolicy,
25 },
26 msg::HttpMessage,
27};
28
29use std::{
30 net::SocketAddr,
31 pin::Pin,
32};
33
34use tokio::io::AsyncWriteExt;
35
36
37/// Through the real wire parser: building the message instead would skip the name normalisation the
38/// parser does, and the point of these tests is what happens to bytes that arrived from outside.
39async fn parse_request(raw: &str) -> Outcome<HttpMessage> {
40 let (mut near, mut far) = tokio::io::duplex(8192);
41 let bytes = raw.as_bytes().to_vec();
42 tokio::spawn(async move {
43 let _ = far.write_all(&bytes).await;
44 let _ = far.flush().await;
45 });
46 let read = HttpMessage::read::<1024, 1024, _>(
47 Pin::new(&mut near),
48 &Vec::new(),
49 Some(true),
50 None,
51 ).await;
52 match res!(read) {
53 (Some(msg), _) => Ok(msg),
54 (None, _) => Err(err!("The test request did not parse."; Test, Invalid, Input)),
55 }
56}
57
58/// Every value of a header, in the order it appears in a raw request head.
59///
60/// The comparison is case-insensitive on the name, so a head that names the field differently from
61/// the test still has its values counted -- otherwise a strip that merely changed the case of a
62/// forgery would read as a strip.
63fn values_of(head: &str, name: &str) -> Vec<String> {
64 let mut out = Vec::new();
65 for line in head.split("\r\n") {
66 if let Some((held, value)) = line.split_once(':') {
67 if held.trim().eq_ignore_ascii_case(name) {
68 out.push(value.trim().to_string());
69 }
70 }
71 }
72 out
73}
74
75/// The forwarding header name as the wire parser produces it.
76fn xff() -> HeaderName {
77 HeaderName::from("x-forwarded-for")
78}
79
80/// A caller's request carrying a full set of forged forwarding headers.
81fn forged_upgrade() -> &'static str {
82 "GET /ws HTTP/1.1\r\n\
83 Host: app.example\r\n\
84 Upgrade: websocket\r\n\
85 X-Forwarded-For: 9.9.9.9\r\n\
86 x-forwarded-for: 8.8.8.8, 7.7.7.7\r\n\
87 X-FORWARDED-FOR: 6.6.6.6\r\n\
88 X-Forwarded-Proto: http\r\n\
89 X-Forwarded-Host: evil.example\r\n\
90 Forwarded: for=9.9.9.9;proto=http;host=evil.example\r\n\
91 \r\n"
92}
93
94/// With nobody trusted, every forged forwarding header is dropped and this hop's own appended.
95///
96/// The forgery is sent in three casings and as a chain, because a caller chooses all of that. What
97/// the upstream must see is one value of each, and it must be the hop's.
98#[tokio::test]
99async fn test_upgrade_head_strips_a_forgery_00() -> Outcome<()> {
100 let request = res!(parse_request(forged_upgrade()).await);
101 let peer: SocketAddr = res!("203.0.113.7:51000".parse::<SocketAddr>(), Test);
102 let head = build_upgrade_request_head(
103 "/ws", "127.0.0.1", &request, &peer, &ForwardedPolicy::none());
104
105 assert_eq!(values_of(&head, "x-forwarded-for"), vec![fmt!("203.0.113.7:51000")],
106 "head was:\n{}", head);
107 assert_eq!(values_of(&head, "x-forwarded-proto"), vec![fmt!("https")],
108 "head was:\n{}", head);
109 assert_eq!(values_of(&head, "x-forwarded-host"), vec![fmt!("app.example")],
110 "the upstream must see the host the client addressed, once; head was:\n{}", head);
111 assert_eq!(values_of(&head, "forwarded"),
112 vec![fmt!("for=\"203.0.113.7:51000\";proto=https;host=\"app.example\"")],
113 "head was:\n{}", head);
114 for forged in ["9.9.9.9", "8.8.8.8", "7.7.7.7", "6.6.6.6", "evil.example"] {
115 assert!(!head.contains(forged),
116 "the forged value '{}' reached the upstream:\n{}", forged, head);
117 }
118
119 // The caller's own headers still ride through. A strip that took the rest with it would be a
120 // different outage, and the upgrade cannot complete without these.
121 assert_eq!(values_of(&head, "upgrade"), vec![fmt!("websocket")], "head was:\n{}", head);
122 assert_eq!(values_of(&head, "connection"), vec![fmt!("Upgrade")], "head was:\n{}", head);
123 assert_eq!(values_of(&head, "host"), vec![fmt!("127.0.0.1")],
124 "the Host belongs to this hop, not to the caller's original; head was:\n{}", head);
125 assert!(head.ends_with("\r\n\r\n"), "the head must end with a blank line:\n{}", head);
126 Ok(())
127}
128
129/// A caller that sends nothing still has this hop's account appended.
130///
131/// Stripping is not the whole job. If the strip ran and the append did not, the upstream would fall
132/// back to its socket peer -- which behind a proxy is loopback, and loopback is the address a
133/// trusted gateway path is written for.
134#[tokio::test]
135async fn test_this_hop_names_itself_when_the_caller_said_nothing_00() -> Outcome<()> {
136 let raw = "GET /ws HTTP/1.1\r\n\
137 Host: app.example\r\n\
138 Upgrade: websocket\r\n\
139 \r\n";
140 let request = res!(parse_request(raw).await);
141 let peer: SocketAddr = res!("198.51.100.200:51004".parse::<SocketAddr>(), Test);
142 let head = build_upgrade_request_head(
143 "/ws", "127.0.0.1", &request, &peer, &ForwardedPolicy::none());
144
145 assert_eq!(values_of(&head, "x-forwarded-for"), vec![fmt!("198.51.100.200:51004")]);
146 assert_eq!(values_of(&head, "x-forwarded-proto"), vec![fmt!("https")]);
147 assert_eq!(values_of(&head, "x-forwarded-host"), vec![fmt!("app.example")]);
148 assert_eq!(values_of(&head, "forwarded"),
149 vec![fmt!("for=\"198.51.100.200:51004\";proto=https;host=\"app.example\"")]);
150 Ok(())
151}
152
153/// The same properties on the HTTP proxy head, and the trusted branch on the same call site.
154///
155/// The string asserted on here is the one written to the upstream socket, headers and terminating
156/// blank line included -- a caller writes exactly this and then the body.
157#[tokio::test]
158async fn test_http_proxy_head_strips_and_appends_00() -> Outcome<()> {
159 let raw = "POST /api/thing HTTP/1.1\r\n\
160 Host: app.example\r\n\
161 Content-Type: application/json\r\n\
162 Content-Length: 2\r\n\
163 X-Forwarded-For: 9.9.9.9\r\n\
164 X-Forwarded-Proto: http\r\n\
165 X-Forwarded-Host: evil.example\r\n\
166 Forwarded: for=9.9.9.9\r\n\
167 \r\n{}";
168 let request = res!(parse_request(raw).await);
169 let peer: SocketAddr = res!("203.0.113.7:52000".parse::<SocketAddr>(), Test);
170
171 let head = build_proxy_request_head(
172 "POST", "/thing", "127.0.0.1", &request, &peer, &ForwardedPolicy::none(), 2);
173
174 assert_eq!(values_of(&head, "x-forwarded-for"), vec![fmt!("203.0.113.7:52000")],
175 "head was:\n{}", head);
176 assert_eq!(values_of(&head, "x-forwarded-proto"), vec![fmt!("https")],
177 "head was:\n{}", head);
178 assert_eq!(values_of(&head, "x-forwarded-host"), vec![fmt!("app.example")],
179 "head was:\n{}", head);
180 assert_eq!(values_of(&head, "forwarded"),
181 vec![fmt!("for=\"203.0.113.7:52000\";proto=https;host=\"app.example\"")],
182 "head was:\n{}", head);
183 assert!(!head.contains("9.9.9.9") && !head.contains("evil.example"),
184 "a forgery reached the upstream:\n{}", head);
185
186 // The hop's own framing, and the caller's content type untouched.
187 assert!(head.starts_with("POST /thing HTTP/1.1\r\nHost: 127.0.0.1\r\n"), "head was:\n{}", head);
188 assert_eq!(values_of(&head, "content-length"), vec![fmt!("2")], "head was:\n{}", head);
189 assert_eq!(values_of(&head, "connection"), vec![fmt!("close")], "head was:\n{}", head);
190 assert_eq!(values_of(&head, "content-type"), vec![fmt!("application/json")],
191 "head was:\n{}", head);
192 assert!(head.ends_with("\r\n\r\n"), "the head must end with a blank line:\n{}", head);
193
194 // And with the peer trusted, the same call site preserves the chain. This is what a content
195 // delivery network needs: strip unconditionally and the real client address is discarded
196 // rather than preserved, which is the same bug wearing a safer face.
197 let policy = res!(ForwardedPolicy::new(&[fmt!("203.0.113.7")]));
198 let head = build_proxy_request_head(
199 "POST", "/thing", "127.0.0.1", &request, &peer, &policy, 2);
200 assert_eq!(values_of(&head, "x-forwarded-for"),
201 vec![fmt!("9.9.9.9"), fmt!("203.0.113.7:52000")], "head was:\n{}", head);
202 assert_eq!(values_of(&head, "x-forwarded-proto"), vec![fmt!("http"), fmt!("https")],
203 "head was:\n{}", head);
204 Ok(())
205}
206
207/// A `Host` that could not be a host is not repeated into a header this hop writes.
208///
209/// The value would otherwise be quoted into `Forwarded`, where a `"` ends the quoted string early
210/// and the rest becomes parameters this hop never wrote.
211#[tokio::test]
212async fn test_an_unsafe_host_is_not_repeated_00() -> Outcome<()> {
213 let raw = "GET /thing HTTP/1.1\r\n\
214 Host: app.example\";proto=http;secret=\"x\r\n\
215 \r\n";
216 let request = res!(parse_request(raw).await);
217 let peer: SocketAddr = res!("203.0.113.7:52001".parse::<SocketAddr>(), Test);
218 let head = build_proxy_request_head(
219 "GET", "/thing", "127.0.0.1", &request, &peer, &ForwardedPolicy::none(), 0);
220
221 assert!(values_of(&head, "x-forwarded-host").is_empty(),
222 "a host outside the host grammar is dropped, not passed on:\n{}", head);
223 assert_eq!(values_of(&head, "forwarded"), vec![fmt!("for=\"203.0.113.7:52001\";proto=https")],
224 "head was:\n{}", head);
225 Ok(())
226}
227
228/// **The invariant, read the way an upstream reads it: this hop's value is last, either way.**
229///
230/// Untrusted, there is one value and it is this hop's. Trusted, the caller's chain is kept and this
231/// hop's is appended after it. So `get_last` returns this hop's under both configurations, while
232/// `get_one` returns the caller's whenever there is a caller's to return -- which is the whole
233/// reason `get_last` exists.
234///
235/// The head is put back through the wire parser rather than scanned as a string, because what is
236/// under test is what a reader of the message gets, not what the writer believed it wrote.
237#[tokio::test]
238async fn test_this_hops_value_is_last_under_either_policy_00() -> Outcome<()> {
239 let request = res!(parse_request(forged_upgrade()).await);
240 let peer: SocketAddr = res!("203.0.113.7:51000".parse::<SocketAddr>(), Test);
241
242 // Untrusted: the caller's copies are gone, and the one value left is this hop's.
243 let head = build_upgrade_request_head(
244 "/ws", "127.0.0.1", &request, &peer, &ForwardedPolicy::none());
245 let seen = res!(parse_request(&head).await);
246 let last = res!(seen.header.fields.get_last(&xff()).ok_or_else(|| err!(
247 "This hop always appends its own X-Forwarded-For."; Test, Missing)));
248 assert_eq!(fmt!("{}", last), "203.0.113.7:51000",
249 "untrusted: the last value must be this hop's; head was:\n{}", head);
250 let first = res!(seen.header.fields.get_one(&xff()).ok_or_else(|| err!(
251 "This hop always appends its own X-Forwarded-For."; Test, Missing)));
252 assert_eq!(fmt!("{}", first), "203.0.113.7:51000",
253 "untrusted: there is only one value, so first and last agree; head was:\n{}", head);
254
255 // Trusted: the caller's chain survives, and this hop's value still comes last.
256 let policy = res!(ForwardedPolicy::new(&[fmt!("203.0.113.0/24")]));
257 let head = build_upgrade_request_head("/ws", "127.0.0.1", &request, &peer, &policy);
258 let seen = res!(parse_request(&head).await);
259 let last = res!(seen.header.fields.get_last(&xff()).ok_or_else(|| err!(
260 "This hop always appends its own X-Forwarded-For."; Test, Missing)));
261 assert_eq!(fmt!("{}", last), "203.0.113.7:51000",
262 "trusted: the last value must still be this hop's; head was:\n{}", head);
263 let first = res!(seen.header.fields.get_one(&xff()).ok_or_else(|| err!(
264 "The caller's chain was preserved."; Test, Missing)));
265 assert_ne!(fmt!("{}", first), "203.0.113.7:51000",
266 "trusted: the first value is the caller's, which is the trap get_last avoids; head was:\n{}",
267 head);
268 Ok(())
269}