//! Per-process credential hook and pinned-address HTTP transport. //! Transport derives from Wasmtime 49's default sender, with a policy check before connect. use crate::cred::{Caller, Cred, inject::Outcome, script::Script}; use http_body_util::{BodyExt, Full}; use std::sync::Arc; use wasmtime_wasi_http::{Error, RequestOptions}; use wasmtime_wasi_http::{WasiBody, WasiHttpHooks}; type Io = Box> + Send>; pub type Router = Arc; pub(super) struct Hooks { pub caller: Caller, pub cred: Arc, pub script: Arc, pub policy: Arc, pub router: Router, } fn response(response: Response) -> (Response, Io) { ( response.map(|b| Full::new(b).map_err(|n| match n {}).boxed_unsync()), Box::new(async { Ok(()) }), ) } impl WasiHttpHooks for Hooks { // Admit every scheme until the URI supplies the host name. fn is_supported_scheme(&mut self, _: &http::uri::Scheme) -> bool { true } fn send_request( &mut self, request: Request, options: Option, fut: Io, ) -> Box, Io), Error>> + Send> { drop(fut); let caller = self.caller.clone(); let cred = self.cred.clone(); let script = self.script.clone(); let policy = self.policy.clone(); let router = self.router.clone(); Box::new(async move { let (parts, mut body) = request.into_parts(); if parts .uri .host() .is_some_and(|h| h.eq_ignore_ascii_case("host.sausage")) { let bytes = body.collect().await?.to_bytes(); let request = Request::from_parts(parts, bytes); return Ok(( crate::api::dispatch(&router, caller, request).await, Box::new(async { Ok(()) }) as Io, )); } if !matches!(parts.uri.scheme_str(), Some("http" | "https")) { while let Some(frame) = body.frame().await { frame?; } return Err(Error::HttpRequestUriInvalid); } let result = tokio::task::spawn_blocking(move || cred.inject(parts, &caller, script.as_ref())) .await; let mut parts = match result { Ok(Ok(Outcome::Forward(parts))) => parts, outcome => { // Drain without retaining uploads, so the guest can finish its output stream. while let Some(frame) = body.frame().await { frame?; } let (status, value) = match outcome { Ok(Ok(Outcome::Denied { item, request })) => ( 403, serde_json::json!({"message": format!("Access to {item} needs approval. Request {request} awaits a decision."), "item": item, "request": request}), ), Ok(Err(e)) => (502, serde_json::json!({"error": e.to_string()})), _ => ( 502, serde_json::json!({"error": "credential worker failed"}), ), }; return Ok(response( Response::builder() .status(status) .header("content-type", "application/json") .body(Bytes::from(serde_json::to_vec(&value).unwrap())) .unwrap(), )); } }; let authority = parts.uri.authority().ok_or(Error::HttpRequestUriInvalid)?; if authority.as_str().contains('@') { return Err(Error::HttpRequestUriInvalid); } parts.headers.insert( http::header::HOST, authority .as_str() .parse() .map_err(|_| Error::HttpRequestUriInvalid)?, ); for value in parts.headers.values_mut() { value.set_sensitive(true); } // Hyper otherwise discards unknown-length GET bodies before WASI finishes them. if !parts.headers.contains_key(http::header::CONTENT_LENGTH) { parts.headers.insert( http::header::TRANSFER_ENCODING, http::HeaderValue::from_static("chunked"), ); } let (res, io) = send_request(Request::from_parts(parts, body), options, &policy).await?; Ok((res.map(BodyExt::boxed_unsync), Box::new(io) as Io)) }) } } use bytes::Bytes; use core::future::poll_fn; use core::pin::{Pin, pin}; use core::task::{Poll, ready}; use http::uri::Scheme; use http::{Request, Response}; use http_body::Body; use std::time::Duration; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; trait TokioStream: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static { fn boxed(self) -> Box where Self: Sized, { Box::new(self) } } impl TokioStream for T where T: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static {} /// Preserve Wasmtime's HTTP/1 transport and request deadlines, but connect only to checked addresses. /// The returned future drives the connection after the response headers arrive. async fn send_request + Send + 'static>( mut req: Request, options: Option, policy: &super::net::NetPolicy, ) -> Result< ( Response + use>, impl Future> + Send + use, ), Error, > { let uri = req.uri().clone(); let authority = uri.authority().ok_or(Error::HttpRequestUriInvalid)?; let use_tls = uri.scheme() == Some(&Scheme::HTTPS); let authority = if authority.port().is_some() { authority.to_string() } else { let port = if use_tls { 443 } else { 80 }; format!("{authority}:{port}") }; let connect_timeout = options .and_then(|r| r.connect_timeout) .unwrap_or(Duration::from_secs(600)); let first_byte_timeout = options .and_then(|r| r.first_byte_timeout) .unwrap_or(Duration::from_secs(600)); let between_bytes_timeout = options .and_then(|r| r.between_bytes_timeout) .unwrap_or(Duration::from_secs(600)); let stream = match tokio::time::timeout(connect_timeout, async { let addresses = policy.resolve(&uri).await?; TcpStream::connect(addresses.as_slice()) .await .map_err(Error::Connect) }) .await { Ok(stream) => stream?, Err(..) => return Err(Error::ConnectionTimeout), }; let stream = if use_tls { // derived from https://github.com/rustls/rustls/blob/main/examples/src/bin/simpleclient.rs let root_cert_store = rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.into(), }; let config = rustls::ClientConfig::builder() .with_root_certificates(root_cert_store) .with_no_client_auth(); let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(config)); let domain = tls_server_name(&authority)?; let stream = connector .connect(domain, stream) .await .map_err(Error::Tls)?; stream.boxed() } else { stream.boxed() }; let (mut sender, conn) = tokio::time::timeout( connect_timeout, hyper::client::conn::http1::Builder::new().handshake(hyper_util::rt::TokioIo::new(stream)), ) .await .map_err(|_| Error::ConnectionTimeout)??; // Send origin-form URIs, because this connection does not use a proxy. *req.uri_mut() = http::Uri::builder() .path_and_query( req.uri() .path_and_query() .map(|p| p.as_str()) .unwrap_or("/"), ) .build() .expect("comes from valid request"); let send = async move { use core::task::Context; /// Wrapper around [hyper::body::Incoming] used to /// account for request option timeout configuration struct IncomingResponseBody { incoming: hyper::body::Incoming, timeout: tokio::time::Interval, } impl http_body::Body for IncomingResponseBody { type Data = ::Data; type Error = Error; fn poll_frame( mut self: Pin<&mut Self>, cx: &mut Context<'_>, ) -> Poll, Self::Error>>> { match Pin::new(&mut self.as_mut().incoming).poll_frame(cx) { Poll::Ready(None) => Poll::Ready(None), Poll::Ready(Some(Err(err))) => { let err = if err.is_timeout() { Error::HttpResponseTimeout } else { Error::from(err) }; Poll::Ready(Some(Err(err))) } Poll::Ready(Some(Ok(frame))) => { self.timeout.reset(); Poll::Ready(Some(Ok(frame))) } Poll::Pending => { ready!(self.timeout.poll_tick(cx)); Poll::Ready(Some(Err(Error::ConnectionReadTimeout))) } } } fn is_end_stream(&self) -> bool { self.incoming.is_end_stream() } fn size_hint(&self) -> http_body::SizeHint { self.incoming.size_hint() } } let res = tokio::time::timeout(first_byte_timeout, sender.send_request(req)) .await .map_err(|_| Error::ConnectionReadTimeout)? .map_err(Error::from)?; // Tokio panics on an `interval` with a zero duration, so clamp it to a // 1ns timeout which is sort of effectively zero anyway. let between_bytes_timeout = if between_bytes_timeout == Duration::ZERO { Duration::new(0, 1) } else { between_bytes_timeout }; let mut timeout = tokio::time::interval(between_bytes_timeout); timeout.reset(); Ok(res.map(|incoming| IncomingResponseBody { incoming, timeout })) }; let mut send = pin!(send); let mut conn = Some(conn); // Wait for response while driving connection I/O let res = poll_fn(|cx| match send.as_mut().poll(cx) { Poll::Ready(Ok(res)) => Poll::Ready(Ok(res)), Poll::Ready(Err(err)) => Poll::Ready(Err(err)), Poll::Pending => { // Response is not ready, poll `hyper` connection to drive I/O if it has not completed yet let Some(fut) = conn.as_mut() else { // `hyper` connection already completed return Poll::Pending; }; let res = ready!(Pin::new(fut).poll(cx)); // `hyper` connection completed, record that to prevent repeated poll conn = None; match res { // `hyper` connection has successfully completed, optimistically poll for response Ok(()) => send.as_mut().poll(cx), // `hyper` connection has failed, return the error Err(err) => Poll::Ready(Err(Error::from(err))), } } }) .await?; Ok((res, async move { let Some(conn) = conn.take() else { // `hyper` connection has already completed return Ok(()); }; if let Err(err) = conn.await { if err.is_timeout() { return Err(Error::HttpResponseTimeout); } return Err(err.into()); } Ok(()) })) } /// Resolve the rustls [`ServerName`] used for TLS certificate verification from /// an outbound request `authority`. /// /// `authority` is in `host:port` form, where an IPv6 `host` is wrapped in /// brackets (for example `[::1]:443`). An IP literal is recognized by parsing /// the whole authority as a [`SocketAddr`]; this handles the bracketed IPv6 /// form, which splitting on the first `:` would truncate. Anything else is /// treated as a host name, with the port stripped off before it is handed to /// rustls. /// /// [`ServerName`]: rustls::pki_types::ServerName /// [`SocketAddr`]: std::net::SocketAddr pub(crate) fn tls_server_name( authority: &str, ) -> Result, rustls::pki_types::InvalidDnsNameError> { use rustls::pki_types::ServerName; if let Ok(addr) = authority.parse::() { return Ok(ServerName::from(addr.ip())); } let host = match authority.split_once(':') { Some((host, _port)) => host, None => authority, }; Ok(ServerName::try_from(host)?.to_owned()) } #[cfg(test)] mod tls_server_name_tests { use super::tls_server_name; use rustls::pki_types::ServerName; #[test] fn resolves_server_name_from_authority() { // Host names keep their host and drop the port. assert_eq!( tls_server_name("example.com:443").unwrap(), ServerName::try_from("example.com").unwrap() ); assert_eq!( tls_server_name("example.com").unwrap(), ServerName::try_from("example.com").unwrap() ); // IP literals resolve to an `IpAddress` server name. The bracketed IPv6 // form must not be truncated at the first `:`. assert_eq!( tls_server_name("127.0.0.1:80").unwrap(), ServerName::from(std::net::Ipv4Addr::LOCALHOST) ); assert_eq!( tls_server_name("[::1]:443").unwrap(), ServerName::from(std::net::Ipv6Addr::LOCALHOST) ); assert_eq!( tls_server_name("[2001:db8::1]:8443").unwrap(), ServerName::from("2001:db8::1".parse::().unwrap()) ); } }