diff --git a/crates/wasi-http/src/handler.rs b/crates/wasi-http/src/handler.rs index 45e20780d202..95b3b3eca7db 100644 --- a/crates/wasi-http/src/handler.rs +++ b/crates/wasi-http/src/handler.rs @@ -992,6 +992,80 @@ pub enum Prepared<'a, T: 'static> { }, } +/// A request authority is `uri-host [ ":" port ]`, without userinfo +/// (RFC 3986 section 3.2, RFC 9110 section 7.2). +fn validate_authority(authority: &http::uri::Authority) -> Result<()> { + if authority.as_str().contains('@') { + return Err(bad_request("HTTP request has an invalid authority")); + } + let host = authority.host(); + if host.is_empty() { + return Err(bad_request("HTTP request has an invalid authority")); + } + // `Authority` stores the port as a string; a non-numeric or empty port + // parses as an authority with no port, so check the suffix directly. + let rest = &authority.as_str()[host.len()..]; + if !rest.is_empty() && (!rest.starts_with(':') || rest[1..].parse::().is_err()) { + return Err(bad_request("HTTP request has an invalid authority")); + } + Ok(()) +} + +/// Determine the authority of the incoming request's target, validating `Host` +/// (RFC 9112 section 3.2) and its relation to `:authority` (RFC 9113 section 8.3.1). +fn get_request_target(request: &Request) -> Result { + use http::header::HOST; + + let mut hosts = request.headers().get_all(HOST).iter(); + let host = hosts.next(); + if hosts.next().is_some() { + return Err(bad_request("HTTP request has multiple Host header fields")); + } + let host = match host { + Some(host) => { + let host = host + .to_str() + .map_err(|_| bad_request("HTTP request has a non-ASCII Host header value"))?; + let host = host + .parse::() + .map_err(|_| bad_request("HTTP request has an invalid Host header value"))?; + validate_authority(&host)?; + Some(host) + } + None => None, + }; + let authority = request.uri().authority().cloned(); + if let Some(authority) = &authority { + validate_authority(authority)?; + } + match (authority, host) { + (Some(authority), Some(host)) => { + // When both are present HTTP/2 requires them to agree; HTTP/1.1 + // absolute-form instead ignores `Host` (RFC 9112 section 3.2.2). + if request.version() == http::Version::HTTP_2 + && !authority.as_str().eq_ignore_ascii_case(host.as_str()) + { + return Err(bad_request("HTTP request has an invalid Host header value")); + } + Ok(authority) + } + (Some(authority), None) => { + // HTTP/1.1 requires `Host` even for absolute-form; HTTP/2 does not. + if request.version() == http::Version::HTTP_2 { + Ok(authority) + } else { + Err(bad_request("HTTP request is missing a Host header field")) + } + } + (None, Some(host)) => Ok(host), + (None, None) => Err(bad_request("HTTP request is missing a Host header field")), + } +} + +fn bad_request(message: &'static str) -> wasmtime::Error { + wasmtime::Error::msg(message).context(crate::ErrorResponse::new(http::StatusCode::BAD_REQUEST)) +} + impl<'a, T: Send> Prepared<'a, T> { /// Creates a new prepared request. pub fn new( @@ -1004,8 +1078,17 @@ impl<'a, T: Send> Prepared<'a, T> { match proxy { #[cfg(feature = "p3")] Proxy::P3(guest) => { - use http::header::HOST; - use http::uri::Authority; + let authority = match get_request_target(&request) { + Ok(authority) => authority, + Err(e) => { + // The request never reached the guest, so report the + // failure through `tx` like the p2 path does. + _ = tx.send(Err(e)); + wasmtime::bail!( + "request was rejected before it could be turned into a guest request" + ); + } + }; let (parts, body) = request.into_parts(); let http::request::Parts { @@ -1014,20 +1097,7 @@ impl<'a, T: Send> Prepared<'a, T> { headers: request_headers, .. } = parts; - let http::uri::Parts { - authority, - path_and_query, - .. - } = uri.into_parts(); - // HTTP/1.1 requires an authority in the request target or the - // `Host` header (RFC 9112 section 3.2.2), so fall back to the latter. - let authority = match authority { - Some(authority) => Some(authority), - None => request_headers - .get(HOST) - .and_then(|host| host.to_str().ok()) - .and_then(|host| host.parse::().ok()), - }; + let http::uri::Parts { path_and_query, .. } = uri.into_parts(); let hooks = view(store.data_mut()).hooks; let headers = crate::FieldMap::new_immutable(hooks, request_headers); // `serve` speaks plaintext HTTP, so that is the scheme of every @@ -1035,7 +1105,7 @@ impl<'a, T: Send> Prepared<'a, T> { let (request, request_io_result) = p3::Request::new( method, Some(http::uri::Scheme::HTTP), - authority, + Some(authority), path_and_query, headers, None, @@ -1056,6 +1126,15 @@ impl<'a, T: Send> Prepared<'a, T> { } #[cfg(feature = "p2")] Proxy::P2(guest) => { + // The p2 path builds its own copy of the request, so only the + // request-target checks of the helper are used here. + if let Err(e) = get_request_target(&request) { + _ = tx.send(Err(e)); + wasmtime::bail!( + "request was rejected before it could be turned into a guest request" + ); + } + // Here we wrap the sender in an `Arc>>`, with one // clone used in the `response-outparam` and the other used to send // an error if the request expires or the handler returns without diff --git a/tests/all/cli_tests.rs b/tests/all/cli_tests.rs index 75d79f50fc76..38aa6f2a2216 100644 --- a/tests/all/cli_tests.rs +++ b/tests/all/cli_tests.rs @@ -1673,6 +1673,20 @@ mod test_programs { send: &mut hyper::client::conn::http1::SendRequest, req: http::Request, ) -> Result> { + // RFC 9112 section 3.2: a client sends `Host` in every HTTP/1.1 + // request, absolute-form included, so fill it in from the target. + let has_host = req.headers().contains_key(http::header::HOST); + let authority = req.uri().authority().cloned(); + let req = match (has_host, authority) { + (false, Some(authority)) => { + let mut req = req; + let value = http::header::HeaderValue::from_str(authority.as_str()) + .context("failed to make Host header value")?; + req.headers_mut().insert(http::header::HOST, value); + req + } + _ => req, + }; let response = send .send_request(req) .await @@ -2210,6 +2224,127 @@ start a print 1234 Ok(()) } + /// Write a raw HTTP request to a serve instance and read the whole response. + async fn serve_raw_request(server: &WasmtimeServe, request: &[u8]) -> Result> { + let mut stream = TcpStream::connect(server.first_addr()).await?; + stream.write_all(request).await?; + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(10), stream.read_to_end(&mut response)) + .await + .expect("timed out waiting for a response")?; + Ok(response) + } + + /// Send the RFC 9112 section 3.2 request table to `server`: one request with + /// a valid `Host` and the requests that must be rejected with a 400. + async fn test_rfc_9112_sec_3_2(server: &WasmtimeServe) -> Result<()> { + let cases: &[(&str, &[u8], &[u8])] = &[ + ( + "valid host", + b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 200", + ), + ( + "two hosts", + b"GET / HTTP/1.1\r\nHost: a.example\r\nHost: b.example\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "non-ascii host", + b"GET / HTTP/1.1\r\nHost: \x80\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "host with userinfo", + b"GET / HTTP/1.1\r\nHost: user@evil.com\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "no host", + b"GET / HTTP/1.1\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "absolute-form without host", + b"GET http://localhost/ HTTP/1.1\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "absolute-form", + b"GET http://localhost/ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 200", + ), + ( + "host with bad port", + b"GET / HTTP/1.1\r\nHost: example.com:abc\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "host with empty port", + b"GET / HTTP/1.1\r\nHost: example.com:\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "host with out of range port", + b"GET / HTTP/1.1\r\nHost: example.com:65536\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "host with empty host", + b"GET / HTTP/1.1\r\nHost: :8080\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ( + "host with port", + b"GET / HTTP/1.1\r\nHost: example.com:8080\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 200", + ), + ( + "host with ipv6", + b"GET / HTTP/1.1\r\nHost: [::1]\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 200", + ), + ( + "absolute-form with userinfo", + b"GET http://user@localhost/ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + b"HTTP/1.1 400", + ), + ]; + for (name, request, expected) in cases { + let response = serve_raw_request(server, request).await?; + assert!( + response.starts_with(expected), + "{name}: expected {:?}, got {:?}", + String::from_utf8_lossy(expected), + String::from_utf8_lossy(&response), + ); + } + + Ok(()) + } + + #[tokio::test] + async fn p2_cli_serve_rfc_9112_sec_3_2() -> Result<()> { + let server = WasmtimeServe::new(P2_CLI_SERVE_HELLO_WORLD_COMPONENT, |cmd| { + cmd.arg("-Scli"); + })?; + test_rfc_9112_sec_3_2(&server).await?; + server.finish()?; + Ok(()) + } + + #[tokio::test] + #[cfg_attr(not(feature = "component-model-async"), ignore)] + async fn p3_cli_serve_rfc_9112_sec_3_2() -> Result<()> { + let server = WasmtimeServe::new(P3_CLI_SERVE_HELLO_WORLD_COMPONENT, |cmd| { + cmd.arg("-Wcomponent-model-async"); + cmd.arg("-Sp3,cli"); + })?; + test_rfc_9112_sec_3_2(&server).await?; + server.finish()?; + Ok(()) + } + #[tokio::test] async fn p2_cli_serve_missing_authority() -> Result<()> { let server = WasmtimeServe::new(P2_CLI_SERVE_HELLO_WORLD_COMPONENT, |cmd| { @@ -2909,6 +3044,63 @@ start a print 1234 Ok(()) } + async fn h2_request_status(uri: &str, hosts: &[&str]) -> Result { + let server = WasmtimeServe::new(P2_CLI_SERVE_HELLO_WORLD_COMPONENT, |cmd| { + cmd.arg("-Scli"); + })?; + let tcp = TcpStream::connect(server.first_addr()).await?; + let (mut send, conn) = hyper::client::conn::http2::Builder::new(TokioExecutor) + .handshake(wasmtime_wasi_http::io::TokioIo::new(tcp)) + .await + .context("failed http2 handshake")?; + let mut conn_task = tokio::task::spawn(conn); + let mut request = hyper::Request::builder() + .uri(uri) + .body(String::new()) + .context("failed to make request")?; + for host in hosts { + request.headers_mut().append("host", host.parse()?); + } + let status = send + .send_request(request) + .await + .context("error sending request")? + .status() + .as_u16(); + drop(send); + if tokio::time::timeout(Duration::from_secs(5), &mut conn_task) + .await + .is_err() + { + conn_task.abort(); + } + server.finish()?; + Ok(status) + } + + #[tokio::test] + async fn p2_cli_serve_host_validation_http2() -> Result<()> { + assert_eq!(h2_request_status("http://a.example/", &[]).await?, 200); + assert_eq!( + h2_request_status("http://a.example/", &["a.example", "a.example"]).await?, + 400 + ); + assert_eq!( + h2_request_status("http://a.example/", &["user@a.example"]).await?, + 400 + ); + assert_eq!( + h2_request_status("http://a.example/", &["a.example"]).await?, + 200 + ); + assert_eq!( + h2_request_status("http://a.example/", &["b.example"]).await?, + 400 + ); + assert_eq!(h2_request_status("http://user@a.example/", &[]).await?, 400); + Ok(()) + } + #[tokio::test] async fn p2_cli_serve_hello_world() -> Result<()> { cli_serve_hello_world(P2_CLI_SERVE_HELLO_WORLD_COMPONENT, 1, 1, |cmd| { diff --git a/tests/wasi.rs b/tests/wasi.rs index 1cad96a0dec1..605f277bc740 100644 --- a/tests/wasi.rs +++ b/tests/wasi.rs @@ -399,6 +399,7 @@ async fn send_request(addr: SocketAddr, req: HttpRequest) -> Result<()> { http::Request::builder() .method(http::Method::from(req.method)) .uri(req.path) + .header(http::header::HOST, "localhost") .body(String::new()) .unwrap(), )