diff --git a/crates/wasi-http/src/error.rs b/crates/wasi-http/src/error.rs index ac85e32be7df..4cdab5f8e2f3 100644 --- a/crates/wasi-http/src/error.rs +++ b/crates/wasi-http/src/error.rs @@ -265,3 +265,29 @@ 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. +#[derive(Debug)] +pub struct ErrorResponse { + status: http::StatusCode, +} + +#[expect(missing_docs, reason = "self-explanatory")] +impl ErrorResponse { + pub fn new(status: http::StatusCode) -> Self { + Self { status } + } + + 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..444c7ae5b3a7 100644 --- a/crates/wasi-http/src/handler.rs +++ b/crates/wasi-http/src/handler.rs @@ -861,6 +861,9 @@ where /// - [`ExpirationError`] if the request expired before it produced a /// response. See [`WorkerState::on_request_start`] for details. /// + /// - [`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 /// used when a trap occurs but cannot be traced to a specific request, @@ -1019,8 +1022,22 @@ 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. + if let Some(tx) = tx.lock().unwrap().take() { + _ = tx.send(Err(e)); + } + wasmtime::bail!( + "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 f4e88d33fbd6..618ef27eba77 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; @@ -1273,22 +1273,28 @@ async fn handle_client( Ok(r) => Ok::<_, Infallible>(r), Err(e) => { eprintln!("error: {e:?}"); - let error_html = "\ + let status = e + .downcast_ref::() + .map(|e| e.status()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + 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 c9a139d642a5..62df4fbc4cba 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,48 @@ 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. 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?; + // 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()?; + Ok(()) + } + #[test] fn p2_cli_argv0() -> Result<()> { run_wasmtime(&["run", "--argv0=a", P2_CLI_ARGV0, "a"])?;