use std::net::SocketAddr;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use http_body_util::{BodyExt, Limited};
use hyper::{Request, Uri};
use tokio::net::TcpStream;
use tokio::sync::Semaphore;
use tokio::time::timeout;
use wasmtime_wasi::runtime::AbortOnDropJoinHandle;
use wasmtime_wasi_http::io::TokioIo;
use wasmtime_wasi_http::p2::bindings::http::types::{DnsErrorPayload, ErrorCode};
use wasmtime_wasi_http::p2::body::HyperOutgoingBody;
use wasmtime_wasi_http::p2::types::{
HostFutureIncomingResponse, IncomingResponse, OutgoingRequestConfig,
};
use wasmtime_wasi_http::p2::{HttpResult, WasiHttpHooks, hyper_request_error};
use crate::outbound::{AddrVerdict, OutboundPolicy, Scheme};
use crate::resolver::Resolver;
pub(crate) struct OutboundState {
policy: Arc<OutboundPolicy>,
permits: Arc<Semaphore>,
resolver: Arc<Resolver>,
}
impl OutboundState {
pub(crate) fn new(policy: OutboundPolicy) -> Self {
let permits = Arc::new(Semaphore::new(policy.max_concurrent as usize));
Self {
policy: Arc::new(policy),
permits,
resolver: Arc::new(Resolver::System),
}
}
pub(crate) fn hooks(&self) -> PlectoHttpHooks {
PlectoHttpHooks {
policy: self.policy.clone(),
permits: self.permits.clone(),
resolver: self.resolver.clone(),
}
}
#[cfg(test)]
fn new_with_resolver(policy: OutboundPolicy, resolver: Resolver) -> Self {
let permits = Arc::new(Semaphore::new(policy.max_concurrent as usize));
Self {
policy: Arc::new(policy),
permits,
resolver: Arc::new(resolver),
}
}
}
pub(crate) struct PlectoHttpHooks {
policy: Arc<OutboundPolicy>,
permits: Arc<Semaphore>,
resolver: Arc<Resolver>,
}
impl PlectoHttpHooks {
pub(crate) fn deny_all() -> Self {
Self {
policy: Arc::new(OutboundPolicy {
allow: Vec::new(),
allow_private: Vec::new(),
connect_timeout: Duration::from_secs(1),
total_timeout: Duration::from_secs(1),
max_response_bytes: 1,
max_concurrent: 1,
}),
permits: Arc::new(Semaphore::new(1)),
resolver: Arc::new(Resolver::System),
}
}
}
impl WasiHttpHooks for PlectoHttpHooks {
fn send_request(
&mut self,
request: Request<HyperOutgoingBody>,
config: OutgoingRequestConfig,
) -> HttpResult<HostFutureIncomingResponse> {
Ok(self.dispatch(request, config))
}
}
impl PlectoHttpHooks {
fn dispatch(
&mut self,
request: Request<HyperOutgoingBody>,
config: OutgoingRequestConfig,
) -> HostFutureIncomingResponse {
let use_tls = config.use_tls;
let scheme = if use_tls { Scheme::Https } else { Scheme::Http };
let Some((host, port)) = authority_of(&request, use_tls) else {
return ready_err(ErrorCode::HttpRequestUriInvalid);
};
if !self.policy.allows(scheme, &host, port) {
return ready_err(ErrorCode::HttpRequestDenied);
}
let Ok(permit) = self.permits.clone().try_acquire_owned() else {
return ready_err(ErrorCode::ConnectionLimitReached);
};
let policy = self.policy.clone();
let resolver = self.resolver.clone();
let total = policy.total_timeout;
let connect_timeout = policy.connect_timeout;
let max_body = policy.max_response_bytes;
let handle = wasmtime_wasi::runtime::spawn(async move {
let _permit = permit; let outcome = timeout(
total,
resolve_and_connect(
&resolver,
&host,
port,
&policy,
use_tls,
connect_timeout,
max_body,
total,
request,
),
)
.await;
let inner = outcome.unwrap_or(Err(ErrorCode::ConnectionTimeout));
Ok::<_, wasmtime::Error>(inner)
});
HostFutureIncomingResponse::pending(handle)
}
}
fn all_addresses_allowed(addrs: &[SocketAddr], policy: &OutboundPolicy) -> bool {
addrs
.iter()
.all(|addr| policy.classify(addr.ip()) == AddrVerdict::Allowed)
}
#[allow(clippy::too_many_arguments)]
async fn resolve_and_connect(
resolver: &Resolver,
host: &str,
port: u16,
policy: &OutboundPolicy,
use_tls: bool,
connect_timeout: Duration,
max_response_bytes: u64,
between_bytes_timeout: Duration,
request: Request<HyperOutgoingBody>,
) -> Result<IncomingResponse, ErrorCode> {
let addrs = resolver.resolve(host, port).await.map_err(|e| {
tracing::debug!(host, port, error = %e, "outbound-http DNS resolution failed");
dns_err()
})?;
let Some(&first_addr) = addrs.first() else {
return Err(dns_err());
};
if !all_addresses_allowed(&addrs, policy) {
return Err(ErrorCode::DestinationIpProhibited);
}
connect_and_send(
first_addr,
host,
use_tls,
connect_timeout,
max_response_bytes,
between_bytes_timeout,
request,
)
.await
}
fn ready_err(code: ErrorCode) -> HostFutureIncomingResponse {
HostFutureIncomingResponse::ready(Ok(Err(code)))
}
fn dns_err() -> ErrorCode {
ErrorCode::DnsError(DnsErrorPayload {
rcode: None,
info_code: None,
})
}
fn authority_of(request: &Request<HyperOutgoingBody>, use_tls: bool) -> Option<(String, u16)> {
let authority = request.uri().authority()?;
let host = authority.host();
if host.is_empty() {
return None;
}
let port = authority
.port_u16()
.unwrap_or(if use_tls { 443 } else { 80 });
Some((host.to_string(), port))
}
#[allow(clippy::too_many_arguments)]
async fn connect_and_send(
addr: SocketAddr,
host: &str,
use_tls: bool,
connect_timeout: Duration,
max_response_bytes: u64,
between_bytes_timeout: Duration,
mut request: Request<HyperOutgoingBody>,
) -> Result<IncomingResponse, ErrorCode> {
let host_header = request
.uri()
.authority()
.and_then(|a| hyper::header::HeaderValue::from_str(a.as_str()).ok());
if !request.headers().contains_key(hyper::header::HOST)
&& let Some(value) = host_header
{
request.headers_mut().insert(hyper::header::HOST, value);
}
let tcp = timeout(connect_timeout, TcpStream::connect(addr))
.await
.map_err(|_| ErrorCode::ConnectionTimeout)?
.map_err(|_| ErrorCode::ConnectionRefused)?;
if let Err(e) = tcp.set_nodelay(true) {
tracing::trace!(error = %e, "outbound-http set_nodelay failed");
}
let (mut sender, worker) = if use_tls {
let tls = tls_connect(host, tcp, connect_timeout).await?;
handshake(TokioIo::new(tls), connect_timeout).await?
} else {
handshake(TokioIo::new(tcp), connect_timeout).await?
};
let path = request
.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string();
*request.uri_mut() = Uri::builder()
.path_and_query(path)
.build()
.map_err(|_| ErrorCode::HttpRequestUriInvalid)?;
let resp = sender
.send_request(request)
.await
.map_err(hyper_request_error)?;
let resp = resp.map(|body| {
Limited::new(body, max_response_bytes as usize)
.map_err(move |_| ErrorCode::HttpResponseBodySize(Some(max_response_bytes)))
.boxed_unsync()
});
Ok(IncomingResponse {
resp,
worker: Some(worker),
between_bytes_timeout,
})
}
async fn handshake<S>(
io: TokioIo<S>,
connect_timeout: Duration,
) -> Result<
(
hyper::client::conn::http1::SendRequest<HyperOutgoingBody>,
AbortOnDropJoinHandle<()>,
),
ErrorCode,
>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let (sender, conn) = timeout(connect_timeout, hyper::client::conn::http1::handshake(io))
.await
.map_err(|_| ErrorCode::ConnectionTimeout)?
.map_err(hyper_request_error)?;
let worker = wasmtime_wasi::runtime::spawn(async move {
if let Err(e) = conn.await {
tracing::trace!(error = %e, "outbound-http connection driver ended with error");
}
});
Ok((sender, worker))
}
async fn tls_connect(
host: &str,
tcp: TcpStream,
connect_timeout: Duration,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, ErrorCode> {
use rustls::pki_types::ServerName;
let config = client_config()?;
let connector = tokio_rustls::TlsConnector::from(config);
let server_name = ServerName::try_from(host.to_string()).map_err(|_| dns_err())?;
timeout(connect_timeout, connector.connect(server_name, tcp))
.await
.map_err(|_| ErrorCode::ConnectionTimeout)?
.map_err(|_| ErrorCode::TlsProtocolError)
}
fn client_config() -> Result<Arc<rustls::ClientConfig>, ErrorCode> {
static CFG: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
if let Some(cfg) = CFG.get() {
return Ok(cfg.clone());
}
let roots = rustls::RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
};
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let cfg = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|_| ErrorCode::TlsProtocolError)?
.with_root_certificates(roots)
.with_no_client_auth();
let cfg = Arc::new(cfg);
Ok(CFG.get_or_init(|| cfg).clone())
}
#[cfg(test)]
fn empty_out_body() -> HyperOutgoingBody {
use bytes::Bytes;
use http_body_util::Empty;
Empty::<Bytes>::new().map_err(|e| match e {}).boxed_unsync()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::outbound::AllowEntry;
use std::collections::HashMap;
use std::net::IpAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn cfg(use_tls: bool) -> OutgoingRequestConfig {
OutgoingRequestConfig {
use_tls,
connect_timeout: Duration::from_secs(2),
first_byte_timeout: Duration::from_secs(2),
between_bytes_timeout: Duration::from_secs(2),
}
}
fn test_policy(allow: Vec<AllowEntry>) -> OutboundPolicy {
OutboundPolicy {
allow,
allow_private: vec![],
connect_timeout: Duration::from_secs(2),
total_timeout: Duration::from_secs(5),
max_response_bytes: 64 * 1024,
max_concurrent: 8,
}
}
fn allow(host: &str, port: u16, scheme: Scheme) -> AllowEntry {
AllowEntry {
scheme,
host: host.to_string(),
port,
}
}
fn req(uri: &str) -> Request<HyperOutgoingBody> {
Request::builder().uri(uri).body(empty_out_body()).unwrap()
}
#[test]
fn all_addresses_allowed_rejects_a_mix_of_allowed_and_blocked() {
let policy = test_policy(vec![]); struct Case {
name: &'static str,
addrs: Vec<IpAddr>,
want: bool,
}
let cases = vec![
Case {
name: "empty (vacuous truth)",
addrs: vec![],
want: true,
},
Case {
name: "all public",
addrs: vec!["93.184.216.34".parse().unwrap(), "1.1.1.1".parse().unwrap()],
want: true,
},
Case {
name: "one loopback among public — rejected wholesale",
addrs: vec![
"93.184.216.34".parse().unwrap(),
"127.0.0.1".parse().unwrap(),
],
want: false,
},
Case {
name: "link-local metadata address",
addrs: vec!["169.254.169.254".parse().unwrap()],
want: false,
},
];
for case in cases {
let addrs: Vec<SocketAddr> = case
.addrs
.iter()
.map(|ip| SocketAddr::new(*ip, 80))
.collect();
let got = all_addresses_allowed(&addrs, &policy);
assert_eq!(got, case.want, "case: {}", case.name);
}
}
fn ready_code(fut: HostFutureIncomingResponse) -> ErrorCode {
match fut {
HostFutureIncomingResponse::Ready(Ok(Err(code))) => code,
_ => panic!("expected a ready error future"),
}
}
async fn pending_code(fut: HostFutureIncomingResponse) -> ErrorCode {
match fut {
HostFutureIncomingResponse::Pending(handle) => match handle.await {
Ok(Ok(_)) => panic!("expected an error, got a response"),
Ok(Err(code)) => code,
Err(e) => panic!("task failed: {e}"),
},
_ => panic!("expected a pending future"),
}
}
async fn spawn_server(body: Vec<u8>) -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let body = body.clone();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = sock.read(&mut buf).await;
let head = format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n", body.len());
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(&body).await;
let _ = sock.flush().await;
});
}
});
addr
}
#[tokio::test]
async fn connect_and_send_plaintext_success() {
let addr = spawn_server(b"hello".to_vec()).await;
let request = req(&format!("http://{addr}/"));
let resp = connect_and_send(
addr,
&addr.ip().to_string(),
false,
Duration::from_secs(2),
1024,
Duration::from_secs(5),
request,
)
.await
.expect("connect_and_send succeeds");
assert_eq!(resp.resp.status(), 200);
let bytes = resp.resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"hello");
}
#[tokio::test]
async fn response_body_cap_is_enforced() {
let addr = spawn_server(vec![b'x'; 100]).await;
let request = req(&format!("http://{addr}/"));
let resp = connect_and_send(
addr,
&addr.ip().to_string(),
false,
Duration::from_secs(2),
10, Duration::from_secs(5),
request,
)
.await
.expect("headers arrive before the body is read");
let collected = resp.resp.into_body().collect().await;
assert!(collected.is_err(), "a body over the cap must error");
}
#[tokio::test]
async fn dispatch_denies_unlisted_host() {
let policy = test_policy(vec![allow("authz.example.com", 443, Scheme::Https)]);
let mut hooks = OutboundState::new(policy).hooks();
let fut = hooks
.send_request(req("https://evil.example.com/"), cfg(true))
.unwrap();
assert!(matches!(ready_code(fut), ErrorCode::HttpRequestDenied));
}
#[tokio::test]
async fn dispatch_denies_wrong_scheme_and_port() {
let policy = test_policy(vec![allow("authz.example.com", 443, Scheme::Https)]);
let mut hooks = OutboundState::new(policy).hooks();
let fut = hooks
.send_request(req("http://authz.example.com/"), cfg(false))
.unwrap();
assert!(matches!(ready_code(fut), ErrorCode::HttpRequestDenied));
}
#[tokio::test]
async fn dispatch_blocks_host_that_resolves_to_loopback() {
let live = spawn_server(b"secret".to_vec()).await;
let policy = test_policy(vec![allow("authz.internal", live.port(), Scheme::Http)]);
let resolver = Resolver::Static(HashMap::from([(
"authz.internal".to_string(),
vec![IpAddr::from([127, 0, 0, 1])],
)]));
let mut hooks = OutboundState::new_with_resolver(policy, resolver).hooks();
let fut = hooks
.send_request(
req(&format!("http://authz.internal:{}/", live.port())),
cfg(false),
)
.unwrap();
assert!(matches!(
pending_code(fut).await,
ErrorCode::DestinationIpProhibited
));
}
#[tokio::test]
async fn dispatch_denies_over_concurrency_limit() {
let mut policy = test_policy(vec![allow("authz.internal", 80, Scheme::Http)]);
policy.max_concurrent = 1;
let state = OutboundState::new(policy);
let mut hooks = state.hooks();
let _held = state.permits.clone().try_acquire_owned().unwrap();
let fut = hooks
.send_request(req("http://authz.internal/"), cfg(false))
.unwrap();
assert!(matches!(ready_code(fut), ErrorCode::ConnectionLimitReached));
}
}