Files
sausage/host/src/process/http.rs
T

379 lines
14 KiB
Rust

//! 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<dyn Future<Output = Result<(), Error>> + Send>;
pub type Router = Arc<crate::api::Api>;
pub(super) struct Hooks {
pub caller: Caller,
pub cred: Arc<Cred>,
pub script: Arc<dyn Script>,
pub policy: Arc<super::net::NetPolicy>,
pub router: Router,
}
fn response(response: Response<Bytes>) -> (Response<WasiBody>, 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<WasiBody>,
options: Option<RequestOptions>,
fut: Io,
) -> Box<dyn Future<Output = Result<(Response<WasiBody>, 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<dyn TokioStream>
where
Self: Sized,
{
Box::new(self)
}
}
impl<T> 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<T: Body<Data = Bytes, Error = Error> + Send + 'static>(
mut req: Request<T>,
options: Option<RequestOptions>,
policy: &super::net::NetPolicy,
) -> Result<
(
Response<impl Body<Data = Bytes, Error = Error> + use<T>>,
impl Future<Output = Result<(), Error>> + Send + use<T>,
),
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 = <hyper::body::Incoming as http_body::Body>::Data;
type Error = Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Self::Data>, 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::ServerName<'static>, rustls::pki_types::InvalidDnsNameError> {
use rustls::pki_types::ServerName;
if let Ok(addr) = authority.parse::<std::net::SocketAddr>() {
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::<std::net::Ipv6Addr>().unwrap())
);
}
}