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 | |
| 18 | use oxedyne_fe2o3_core::prelude::*; |
| 19 | use 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 | |
| 29 | use std::{ |
| 30 | net::SocketAddr, |
| 31 | pin::Pin, |
| 32 | }; |
| 33 | |
| 34 | use 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. |
| 39 | async 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. |
| 63 | fn 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. |
| 76 | fn xff() -> HeaderName { |
| 77 | HeaderName::from("x-forwarded-for") |
| 78 | } |
| 79 | |
| 80 | /// A caller's request carrying a full set of forged forwarding headers. |
| 81 | fn 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] |
| 99 | async 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] |
| 135 | async 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] |
| 158 | async 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] |
| 212 | async 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] |
| 238 | async 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 | } |