From 802b4ab76efaa0d3a3ae336598a92ae4c393a13e Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Sun, 27 Sep 2026 01:46:23 +0800 Subject: [PATCH 1/4] serve: answer 400, not 500, for an HTTP/1.1 request without a Host header RFC 9112 section 3.2 requires a 400 response to an HTTP/1.1 request that lacks a Host header field. `wasmtime serve` currently answers 500 for it: the request is rejected while it is being turned into an incoming request, and that failure is reported the same way as every other request-handling failure. Check for the missing Host in `serve` before dispatching, where the request is still known to be a malformed client request rather than an internal error. --- src/commands/serve.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/commands/serve.rs b/src/commands/serve.rs index f4e88d33fbd6..8a4662fb67b0 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -1269,6 +1269,33 @@ async fn handle_client( None => None, }; let debuggee_store = debuggee_store.as_mut().map(|s| &mut ***s); + // RFC 9112 section 3.2: + // + // A server MUST respond with a 400 (Bad Request) status code + // to any HTTP/1.1 request message that lacks a Host header + // field ... + // + // A request without an authority is rejected further down (see + // `new_incoming_request`), but that happens inside a worker and + // the failure is indistinguishable from an internal error by + // the time it reaches here, so answer it as the client error it + // is before dispatching. + if req.version() == http::Version::HTTP_11 + && req.uri().authority().is_none() + && !req.headers().contains_key(http::header::HOST) + { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("Content-Type", "text/html; charset=UTF-8") + .body( + Full::new(bytes::Bytes::from_static( + b"400 Bad Request

400 Bad Request


wasmtime
", + )) + .map_err(|_| unreachable!()) + .boxed_unsync(), + ) + .unwrap()); + } match handle_request(handler, debuggee_store, req).await { Ok(r) => Ok::<_, Infallible>(r), Err(e) => { From 84a118cb3a0d3c6236c26cb264c8129be0a3e90f Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Sun, 27 Sep 2026 02:54:48 +0800 Subject: [PATCH 2/4] serve: only answer 400 for a missing Host in wasi:http/proxy components --- src/commands/serve.rs | 16 ++++++++++------ tests/all/cli_tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 8a4662fb67b0..932b28312757 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -1275,12 +1275,16 @@ async fn handle_client( // to any HTTP/1.1 request message that lacks a Host header // field ... // - // A request without an authority is rejected further down (see - // `new_incoming_request`), but that happens inside a worker and - // the failure is indistinguishable from an internal error by - // the time it reaches here, so answer it as the client error it - // is before dispatching. - if req.version() == http::Version::HTTP_11 + // This only applies to `wasi:http/proxy` components: turning + // such a request into a guest request fails there because the + // request carries neither a URI authority nor a `Host` header + // (see `HostIncomingRequest::new_incoming_request`), and that + // failure reaches the client as a 500. A `wasi:http/service` + // component handles the same request without error -- the guest + // observes `authority() == none` -- so it is deliberately left + // untouched here. + if matches!(&handler.state().instance, ProxyPre::P2(_)) + && req.version() == http::Version::HTTP_11 && req.uri().authority().is_none() && !req.headers().contains_key(http::header::HOST) { diff --git a/tests/all/cli_tests.rs b/tests/all/cli_tests.rs index c9a139d642a5..1973b3be2df2 100644 --- a/tests/all/cli_tests.rs +++ b/tests/all/cli_tests.rs @@ -973,6 +973,7 @@ mod test_programs { use std::thread::{self, JoinHandle}; use std::time::Duration; use test_programs_artifacts::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use wasmtime::{Result, bail, error::Context as _, format_err}; @@ -2033,6 +2034,34 @@ start a print 1234 Ok(()) } + #[tokio::test] + async fn p2_cli_serve_missing_authority() -> Result<()> { + let server = WasmtimeServe::new(P2_CLI_SERVE_HELLO_WORLD_COMPONENT, |cmd| { + cmd.arg("-Scli"); + })?; + + // hyper's client always synthesizes a `Host` header for HTTP/1.1 + // requests, so write this request out by hand in order to leave both + // the URI authority and the `Host` header off. RFC 9112 section 3.2 + // requires a 400 here, as opposed to the 500 the handler failure would + // otherwise produce. + let mut stream = TcpStream::connect(server.first_addr()).await?; + stream.write_all(b"GET / HTTP/1.1\r\n\r\n").await?; + let mut buf = [0; 256]; + let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buf)) + .await + .expect("timed out waiting for a response")?; + let response = std::str::from_utf8(&buf[..n])?; + assert!( + response.starts_with("HTTP/1.1 400"), + "unexpected response: {response:?}", + ); + drop(stream); + + server.finish()?; + Ok(()) + } + #[test] fn p2_cli_argv0() -> Result<()> { run_wasmtime(&["run", "--argv0=a", P2_CLI_ARGV0, "a"])?; From fdf05d286b5ef6af0a57da3f3f46d77e22fe72f6 Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Tue, 29 Sep 2026 23:29:39 +0800 Subject: [PATCH 3/4] serve: read the status from the error instead of hardcoding 500 --- crates/wasi-http/src/error.rs | 35 +++++++++++++++++++++ crates/wasi-http/src/handler.rs | 29 +++++++++++++++-- crates/wasi-http/src/p2/types.rs | 11 +++++-- src/commands/serve.rs | 53 ++++++++++---------------------- tests/all/cli_tests.rs | 30 +++++++++++++----- 5 files changed, 108 insertions(+), 50 deletions(-) diff --git a/crates/wasi-http/src/error.rs b/crates/wasi-http/src/error.rs index ac85e32be7df..f53da1b6fc9c 100644 --- a/crates/wasi-http/src/error.rs +++ b/crates/wasi-http/src/error.rs @@ -265,3 +265,38 @@ impl fmt::Display for Error { } impl std::error::Error for Error {} + +/// A response that should be returned to the client when a request cannot be +/// turned into a guest request, carried on the error so that the status code +/// survives the trip back up to the embedder. +/// +/// Some requests are rejected before a guest gets to run, so there is no guest +/// response to describe the problem with. Embedders such as `wasmtime serve` +/// otherwise have nothing to go on beyond the generic failure they use for +/// every other error. The fields are private so that more information can be +/// carried here in the future without breaking the API. +#[derive(Debug)] +pub struct ErrorResponse { + status: http::StatusCode, +} + +impl ErrorResponse { + /// Create a new [`ErrorResponse`] that describes a response with the given + /// status code. + pub fn new(status: http::StatusCode) -> Self { + Self { status } + } + + /// The status code of the response that should be returned. + pub fn status(&self) -> http::StatusCode { + self.status + } +} + +impl fmt::Display for ErrorResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "error response: {}", self.status) + } +} + +impl std::error::Error for ErrorResponse {} diff --git a/crates/wasi-http/src/handler.rs b/crates/wasi-http/src/handler.rs index 937e2a41e96e..994c33f3bb61 100644 --- a/crates/wasi-http/src/handler.rs +++ b/crates/wasi-http/src/handler.rs @@ -861,6 +861,11 @@ where /// - [`ExpirationError`] if the request expired before it produced a /// response. See [`WorkerState::on_request_start`] for details. /// + /// - The original error, including anything it carries such as an + /// [`ErrorResponse`](crate::ErrorResponse), if the request was rejected + /// while being converted into a guest request, e.g. a `wasi:http/proxy` + /// request with neither a URI authority nor a `Host` header. + /// /// - [`TrapOrPanicError`] if the worker responsible for handling the /// request trapped or panicked before it produced a response. This may be /// used when a trap occurs but cannot be traced to a specific request, @@ -1019,8 +1024,28 @@ impl<'a, T: Send> Prepared<'a, T> { // producing a response. let tx = Arc::new(Mutex::new(Some(tx))); - let request = - view(store.data_mut()).new_incoming_request(p2_types::Scheme::Http, request)?; + let request = match view(store.data_mut()) + .new_incoming_request(p2_types::Scheme::Http, request) + { + Ok(request) => request, + Err(e) => { + // The request never reached the guest, so there is no + // `response-outparam` to report the failure with, and + // `tx` would otherwise be dropped along with the error. + // Send it back through `tx` so that `handle` returns the + // original error -- and anything it carries with it, + // such as an `ErrorResponse` status. + if let Some(tx) = tx.lock().unwrap().take() { + _ = tx.send(Err(e)); + } + // The failure is on its way to the caller already; the + // error returned here only ends up in the worker's own + // log, so don't report it as a trap or a panic. + return Err(wasmtime::Error::msg( + "request was rejected before it could be turned into a guest request", + )); + } + }; let out = view(store.data_mut()).new_response_outparam_from_callback({ let tx = tx.clone(); diff --git a/crates/wasi-http/src/p2/types.rs b/crates/wasi-http/src/p2/types.rs index b0f1e3d6e2e5..c6c13c179cab 100644 --- a/crates/wasi-http/src/p2/types.rs +++ b/crates/wasi-http/src/p2/types.rs @@ -5,12 +5,12 @@ use crate::p2::{ bindings::http::types::{self, Method, Scheme}, body::{HostIncomingBody, HyperIncomingBody, HyperOutgoingBody}, }; -use crate::{Error, FieldMap, WasiHttpCtxView}; +use crate::{Error, ErrorResponse, FieldMap, WasiHttpCtxView}; use bytes::Bytes; use http_body_util::BodyExt; use hyper::body::Body; +use wasmtime::Result; use wasmtime::component::Resource; -use wasmtime::{Result, bail}; use wasmtime_wasi::p2::Pollable; use wasmtime_wasi::runtime::AbortOnDropJoinHandle; @@ -89,7 +89,12 @@ impl WasiHttpCtxView<'_> { Some(authority) => authority.to_string(), None => match parts.headers.get(http::header::HOST) { Some(host) => host.to_str()?.to_string(), - None => bail!("invalid HTTP request missing authority in URI and host header"), + None => { + return Err(wasmtime::Error::msg( + "invalid HTTP request missing authority in URI and host header", + ) + .context(ErrorResponse::new(http::StatusCode::BAD_REQUEST))); + } }, }; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 932b28312757..9c802d5e1c20 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -29,12 +29,12 @@ use wasmtime::{ use wasmtime_cli_flags::opt::WasmtimeOptionValue; use wasmtime_wasi::p2::{StreamError, StreamResult}; use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; -use wasmtime_wasi_http::WasiHttpCtx; use wasmtime_wasi_http::handler::{ HandlerState, Instance, Prepared, Proxy, ProxyHandler, ProxyPre, ShouldAccept, WorkerExpiration, WorkerState, WorkerStatus, }; use wasmtime_wasi_http::io::TokioIo; +use wasmtime_wasi_http::{ErrorResponse, WasiHttpCtx}; #[cfg(feature = "debug")] use crate::commands::run::RunCommand; @@ -1269,57 +1269,36 @@ async fn handle_client( None => None, }; let debuggee_store = debuggee_store.as_mut().map(|s| &mut ***s); - // RFC 9112 section 3.2: - // - // A server MUST respond with a 400 (Bad Request) status code - // to any HTTP/1.1 request message that lacks a Host header - // field ... - // - // This only applies to `wasi:http/proxy` components: turning - // such a request into a guest request fails there because the - // request carries neither a URI authority nor a `Host` header - // (see `HostIncomingRequest::new_incoming_request`), and that - // failure reaches the client as a 500. A `wasi:http/service` - // component handles the same request without error -- the guest - // observes `authority() == none` -- so it is deliberately left - // untouched here. - if matches!(&handler.state().instance, ProxyPre::P2(_)) - && req.version() == http::Version::HTTP_11 - && req.uri().authority().is_none() - && !req.headers().contains_key(http::header::HOST) - { - return Ok(Response::builder() - .status(StatusCode::BAD_REQUEST) - .header("Content-Type", "text/html; charset=UTF-8") - .body( - Full::new(bytes::Bytes::from_static( - b"400 Bad Request

400 Bad Request


wasmtime
", - )) - .map_err(|_| unreachable!()) - .boxed_unsync(), - ) - .unwrap()); - } match handle_request(handler, debuggee_store, req).await { Ok(r) => Ok::<_, Infallible>(r), Err(e) => { eprintln!("error: {e:?}"); - let error_html = "\ + // Some errors know which status code the client should + // see; everything else is a failure on our side. + let status = e + .downcast_ref::() + .map(|e| e.status()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + // `StatusCode`'s own `Display` renders as the numeric + // code followed by its canonical reason. + let error_html = format!( + "\ - 500 Internal Server Error + {status}
-

500 Internal Server Error

+

{status}


wasmtime
-"; +" + ); Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) + .status(status) .header("Content-Type", "text/html; charset=UTF-8") .body( Full::new(bytes::Bytes::from(error_html)) diff --git a/tests/all/cli_tests.rs b/tests/all/cli_tests.rs index 1973b3be2df2..62df4fbc4cba 100644 --- a/tests/all/cli_tests.rs +++ b/tests/all/cli_tests.rs @@ -2042,20 +2042,34 @@ start a print 1234 // hyper's client always synthesizes a `Host` header for HTTP/1.1 // requests, so write this request out by hand in order to leave both - // the URI authority and the `Host` header off. RFC 9112 section 3.2 - // requires a 400 here, as opposed to the 500 the handler failure would - // otherwise produce. + // the URI authority and the `Host` header off. There is nothing to + // serve such a request with: it is rejected while it is being turned + // into a guest request, before the guest runs at all, so the status + // code comes from the error rather than from a guest response. let mut stream = TcpStream::connect(server.first_addr()).await?; stream.write_all(b"GET / HTTP/1.1\r\n\r\n").await?; - let mut buf = [0; 256]; - let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buf)) - .await - .expect("timed out waiting for a response")?; - let response = std::str::from_utf8(&buf[..n])?; + // The connection is kept alive, so read until the body has arrived + // rather than until EOF. + let mut response = Vec::new(); + let mut buf = [0; 512]; + while !response.ends_with(b"") && response.len() < 8192 { + let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buf)) + .await + .expect("timed out waiting for a response")?; + if n == 0 { + break; + } + response.extend_from_slice(&buf[..n]); + } + let response = String::from_utf8(response)?; assert!( response.starts_with("HTTP/1.1 400"), "unexpected response: {response:?}", ); + assert!( + response.contains("

400 Bad Request

"), + "unexpected response: {response:?}", + ); drop(stream); server.finish()?; From 2bd10ab060bd15e1573015cfc49cbfed6c53daf0 Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Sat, 3 Oct 2026 03:17:04 +0800 Subject: [PATCH 4/4] serve: trim the obvious comments Per review: keep the comment that explains why the error is handed back through tx, and drop the ones that restate the code. ErrorResponse::new and ::status now carry no doc text, which needs #[expect(missing_docs, reason = "self-explanatory")] because this crate is #![deny(missing_docs)]. The error is built with wasmtime::bail!. --- crates/wasi-http/src/error.rs | 13 ++----------- crates/wasi-http/src/handler.rs | 18 +++++------------- src/commands/serve.rs | 4 ---- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/crates/wasi-http/src/error.rs b/crates/wasi-http/src/error.rs index f53da1b6fc9c..4cdab5f8e2f3 100644 --- a/crates/wasi-http/src/error.rs +++ b/crates/wasi-http/src/error.rs @@ -267,27 +267,18 @@ impl fmt::Display for Error { impl std::error::Error for Error {} /// A response that should be returned to the client when a request cannot be -/// turned into a guest request, carried on the error so that the status code -/// survives the trip back up to the embedder. -/// -/// Some requests are rejected before a guest gets to run, so there is no guest -/// response to describe the problem with. Embedders such as `wasmtime serve` -/// otherwise have nothing to go on beyond the generic failure they use for -/// every other error. The fields are private so that more information can be -/// carried here in the future without breaking the API. +/// turned into a guest request. #[derive(Debug)] pub struct ErrorResponse { status: http::StatusCode, } +#[expect(missing_docs, reason = "self-explanatory")] impl ErrorResponse { - /// Create a new [`ErrorResponse`] that describes a response with the given - /// status code. pub fn new(status: http::StatusCode) -> Self { Self { status } } - /// The status code of the response that should be returned. pub fn status(&self) -> http::StatusCode { self.status } diff --git a/crates/wasi-http/src/handler.rs b/crates/wasi-http/src/handler.rs index 994c33f3bb61..444c7ae5b3a7 100644 --- a/crates/wasi-http/src/handler.rs +++ b/crates/wasi-http/src/handler.rs @@ -861,10 +861,8 @@ where /// - [`ExpirationError`] if the request expired before it produced a /// response. See [`WorkerState::on_request_start`] for details. /// - /// - The original error, including anything it carries such as an - /// [`ErrorResponse`](crate::ErrorResponse), if the request was rejected - /// while being converted into a guest request, e.g. a `wasi:http/proxy` - /// request with neither a URI authority nor a `Host` header. + /// - [`ErrorResponse`](crate::ErrorResponse) if the request could not be + /// converted into a guest request. /// /// - [`TrapOrPanicError`] if the worker responsible for handling the /// request trapped or panicked before it produced a response. This may be @@ -1032,18 +1030,12 @@ impl<'a, T: Send> Prepared<'a, T> { // The request never reached the guest, so there is no // `response-outparam` to report the failure with, and // `tx` would otherwise be dropped along with the error. - // Send it back through `tx` so that `handle` returns the - // original error -- and anything it carries with it, - // such as an `ErrorResponse` status. if let Some(tx) = tx.lock().unwrap().take() { _ = tx.send(Err(e)); } - // The failure is on its way to the caller already; the - // error returned here only ends up in the worker's own - // log, so don't report it as a trap or a panic. - return Err(wasmtime::Error::msg( - "request was rejected before it could be turned into a guest request", - )); + wasmtime::bail!( + "request was rejected before it could be turned into a guest request" + ); } }; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 9c802d5e1c20..618ef27eba77 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -1273,14 +1273,10 @@ async fn handle_client( Ok(r) => Ok::<_, Infallible>(r), Err(e) => { eprintln!("error: {e:?}"); - // Some errors know which status code the client should - // see; everything else is a failure on our side. let status = e .downcast_ref::() .map(|e| e.status()) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - // `StatusCode`'s own `Display` renders as the numeric - // code followed by its canonical reason. let error_html = format!( "\