use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use http_body_util::BodyExt;
use wasmtime_wasi_http::{Error as HttpError, RequestOptions, WasiBody};
use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
use act_policy::grant::{HttpConfig, PolicyMode};
use act_policy::net::{self as network, NetworkRule};
#[derive(Clone)]
struct PolicyDnsResolver {
inner: Arc<hclient_dns_system::SystemDns<hclient_rt_tokio::Tokio>>,
seen: Arc<std::sync::Mutex<std::collections::HashMap<String, (usize, usize)>>>,
allow_nets: Arc<Vec<NetworkRule>>,
deny_nets: Arc<Vec<NetworkRule>>,
mode: PolicyMode,
}
impl PolicyDnsResolver {
fn new(cfg: &HttpConfig) -> Self {
Self {
inner: Arc::new(hclient_dns_system::SystemDns::new(hclient_rt_tokio::Tokio)),
seen: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
allow_nets: Arc::new(cfg.allow.iter().map(|r| r.net.clone()).collect()),
deny_nets: Arc::new(cfg.deny.iter().map(|r| r.net.clone()).collect()),
mode: cfg.mode,
}
}
fn keeps_record(&self, host: &str, record: &hclient_dns::Record) -> bool {
match record.rdata {
hclient_dns::RData::A(v4) => self.permits(host, v4.into()),
hclient_dns::RData::Aaaa(v6) => self.permits(host, v6.into()),
hclient_dns::RData::Https(ref ep) => {
ep.ipv4hint
.iter()
.all(|v4| self.permits(host, (*v4).into()))
&& ep
.ipv6hint
.iter()
.all(|v6| self.permits(host, (*v6).into()))
}
_ => true,
}
}
fn filtered_everything(&self, host: &str) -> bool {
self.seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(host)
.is_some_and(|(offered, kept)| *offered > 0 && *kept == 0)
}
fn permits(&self, host: &str, addr: std::net::IpAddr) -> bool {
if network::any_deny_cidr_matches(&self.deny_nets, addr, 0) {
return false;
}
let host_allowed = self.allow_nets.iter().any(|r| {
r.host
.as_deref()
.is_some_and(|pat| network::host_matches(pat, host))
});
let require_allow_cidr = self.mode == PolicyMode::Allowlist
&& !host_allowed
&& self.allow_nets.iter().any(|r| r.cidr.is_some());
if require_allow_cidr {
return self.allow_nets.iter().any(|r| {
r.cidr
.as_deref()
.is_some_and(|c| network::cidr_contains(c, addr))
});
}
true
}
}
impl hclient_dns::Resolve for PolicyDnsResolver {
type Records<'a> =
futures_util::stream::BoxStream<'a, Result<hclient_dns::Record, hclient::Error>>;
fn supports(&self, rtype: u16) -> bool {
hclient_dns::Resolve::supports(&*self.inner, rtype)
}
fn lookup<'a>(&'a self, name: &str, rtype: u16) -> Self::Records<'a> {
self.filtered(name, rtype)
}
}
impl PolicyDnsResolver {
fn filtered<'a>(
&'a self,
name: &str,
rtype: u16,
) -> futures_util::stream::BoxStream<'a, Result<hclient_dns::Record, hclient::Error>> {
use futures_util::StreamExt;
let host = name.to_string();
let upstream: futures_util::stream::BoxStream<'a, _> =
Box::pin(hclient_dns::Resolve::lookup(&*self.inner, name, rtype));
Box::pin(upstream.filter(move |item| {
let keep = match item {
Ok(record) => self.keeps_record(&host, record),
Err(_) => true,
};
let is_address = matches!(
item,
Ok(hclient_dns::Record {
rdata: hclient_dns::RData::A(_) | hclient_dns::RData::Aaaa(_),
..
})
);
if is_address {
let mut seen = self
.seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let counts = seen.entry(host.clone()).or_insert((0, 0));
counts.0 += 1;
if keep {
counts.1 += 1;
}
}
if !keep {
tracing::debug!(%host, "http policy dropped a resolved address");
}
std::future::ready(keep)
}))
}
}
fn redirect_verdict(
cfg: &HttpConfig,
hop: &hclient::redirect::ProposedRedirect<'_>,
) -> hclient::redirect::RedirectVerdict {
use hclient::redirect::RedirectVerdict;
let to = hop.to();
let host = to.host().unwrap_or("");
let scheme = to.scheme_str().unwrap_or("http");
let port = to
.port_u16()
.unwrap_or(if scheme == "https" { 443 } else { 80 });
let allow_nets: Vec<NetworkRule> = cfg.allow.iter().map(|r| r.net.clone()).collect();
let deny_nets: Vec<NetworkRule> = cfg.deny.iter().map(|r| r.net.clone()).collect();
let decision = network::decide(
cfg.mode,
&allow_nets,
&deny_nets,
&network::NetworkCheck::new(host, port),
);
#[allow(clippy::match_same_arms)]
match decision {
act_policy::Decision::Allow => RedirectVerdict::follow(),
act_policy::Decision::Ask => RedirectVerdict::follow(),
act_policy::Decision::Deny => {
tracing::warn!(%to, "http policy: redirect hop blocked");
emit_cap_decision(&CapDecisionRecord::statik_with_reason(
act_types::constants::CAP_HTTP,
&format!("{host}:{port}"),
"",
Decision4::Deny,
&cfg.mode.to_string(),
None,
Some("redirect target outside ceiling"),
));
RedirectVerdict::Refuse("redirect target outside the component's http ceiling")
}
}
}
#[derive(Debug)]
struct CeilingRedirectPolicy(HttpConfig);
impl hclient::redirect::RedirectPolicy for CeilingRedirectPolicy {
fn follow(
&self,
hop: &hclient::redirect::ProposedRedirect<'_>,
) -> hclient::redirect::RedirectVerdict {
redirect_verdict(&self.0, hop)
}
}
#[derive(Clone)]
pub struct ActHttpClient {
client: Arc<hclient::Client>,
resolver: PolicyDnsResolver,
mode: PolicyMode,
}
impl ActHttpClient {
pub fn new(cfg: HttpConfig) -> anyhow::Result<Self> {
let cfg_for_hops = cfg.clone();
act_store::fetch::install_crypto_provider();
let resolver = PolicyDnsResolver::new(&cfg);
let mode = cfg.mode;
let transport = hclient_native::Native::new(
hclient_rt_tokio::Tokio,
hclient_tls_rustls::Rustls::with_webpki_roots(),
resolver.clone(),
)
.h2_keep_alive(hclient_native::H2KeepAlive::new(
std::time::Duration::from_secs(30),
std::time::Duration::from_secs(10),
))
.pool(hclient_native::PoolConfig {
idle_timeout: std::time::Duration::from_secs(600),
..Default::default()
});
let client = hclient::Client::builder(transport)
.redirect(CeilingRedirectPolicy(cfg_for_hops))
.build()
.map_err(|e| anyhow::anyhow!("the HTTP backend cannot serve this policy: {e}"))?;
Ok(Self {
client: Arc::new(client),
resolver,
mode,
})
}
pub async fn send(
&self,
request: http::Request<WasiBody>,
options: Option<RequestOptions>,
) -> Result<
(
http::Response<WasiBody>,
Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>>,
),
HttpError,
> {
const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
let deadline = options
.and_then(|o| o.connect_timeout)
.unwrap_or(DEFAULT_TIMEOUT)
+ options
.and_then(|o| o.first_byte_timeout)
.unwrap_or(DEFAULT_TIMEOUT);
let (method, url, headers, body) = to_request_parts(request)?;
let host = url
.parse::<http::Uri>()
.ok()
.and_then(|u| u.host().map(str::to_string))
.unwrap_or_default();
let mut req = self.client.request(method, &url);
for (name, value) in &headers {
req = req.header(name.as_str(), value.to_str().unwrap_or_default());
}
let resp = match tokio::time::timeout(deadline, req.body(body).send()).await {
Err(_) => return Err(HttpError::ConnectionTimeout),
Ok(Err(e)) => {
if matches!(e.kind(), hclient::ErrorKind::Resolve)
&& self.resolver.filtered_everything(&host)
{
emit_cap_decision(&CapDecisionRecord::statik_with_reason(
act_types::constants::CAP_HTTP,
&host,
"",
Decision4::Deny,
&self.mode.to_string(),
None,
Some("all resolved addresses filtered by CIDR rule"),
));
}
return Err(client_error_to_wasi(e));
}
Ok(Ok(resp)) => resp,
};
let (parts, body) = resp.into_parts();
response_to_wasi(parts, body)
}
}
#[allow(clippy::type_complexity)]
fn to_request_parts(
request: http::Request<WasiBody>,
) -> Result<(http::Method, String, http::HeaderMap, hclient::RequestBody), HttpError> {
let (parts, body) = request.into_parts();
let scheme = parts
.uri
.scheme_str()
.map_or_else(|| "https".into(), str::to_string);
let authority = parts
.uri
.authority()
.map(std::string::ToString::to_string)
.ok_or(HttpError::HttpRequestUriInvalid)?;
let path_and_query = parts
.uri
.path_and_query()
.map_or("/", http::uri::PathAndQuery::as_str);
let url = format!("{scheme}://{authority}{path_and_query}");
let body = hclient::RequestBody::Streaming(Box::new(WasiRequestBody(body)));
Ok((parts.method, url, parts.headers, body))
}
struct WasiRequestBody(WasiBody);
impl http_body::Body for WasiRequestBody {
type Data = bytes::Bytes;
type Error = hclient::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
let inner = unsafe { self.map_unchecked_mut(|s| &mut s.0) };
inner.poll_frame(cx).map(|opt| {
opt.map(|res| {
res.map_err(|_| {
hclient::Error::new(
hclient::ErrorKind::Body,
std::io::Error::other("wasi:http body stream error"),
)
})
})
})
}
}
fn client_error_to_wasi(err: hclient::Error) -> HttpError {
use hclient::ErrorKind;
match err.kind() {
ErrorKind::Timeout(_) => HttpError::ConnectionTimeout,
ErrorKind::Resolve => HttpError::DnsError {
rcode: Some(err.to_string()),
info_code: None,
},
ErrorKind::Connect => HttpError::ConnectionRefused,
ErrorKind::Redirect => HttpError::HttpRequestDenied,
ErrorKind::Body => HttpError::HttpRequestBodySize(None),
ErrorKind::Decode => HttpError::HttpProtocolError,
_ => HttpError::HttpProtocolError,
}
}
type HookResponse = (
http::Response<WasiBody>,
Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>>,
);
fn response_to_wasi<B>(parts: http::response::Parts, body: B) -> Result<HookResponse, HttpError>
where
B: http_body::Body<Data = bytes::Bytes, Error = hclient::Error> + Send + 'static,
{
let mut headers = parts.headers.clone();
headers.remove(http::header::TRANSFER_ENCODING);
headers.remove(http::header::CONTENT_LENGTH);
let body: WasiBody = BodyExt::boxed_unsync(BodyExt::map_err(body, client_error_to_wasi));
let mut builder = http::Response::builder().status(parts.status);
if let Some(hdrs) = builder.headers_mut() {
hdrs.extend(headers);
}
let resp = builder
.body(body)
.map_err(|_| HttpError::HttpProtocolError)?;
let io: Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>> =
Box::pin(async { Ok(()) });
Ok((resp, io))
}
#[cfg(test)]
mod tests {
use super::*;
use act_policy::grant::HttpConfig;
use http::Method;
use http_body_util::combinators::UnsyncBoxBody;
use http_body_util::{BodyExt, Empty};
use std::sync::Mutex;
#[tokio::test(flavor = "current_thread")]
async fn converts_response_status_headers_body() {
let http_resp = http::Response::builder()
.status(200)
.header("x-echo", "hi")
.body(
http_body_util::Full::new(bytes::Bytes::from_static(b"hello"))
.map_err(|_: std::convert::Infallible| unreachable!())
.boxed_unsync(),
)
.unwrap();
let (parts, body) = http_resp.into_parts();
let body = BodyExt::map_err(body, |_| {
hclient::Error::new(hclient::ErrorKind::Body, std::io::Error::other("unused"))
});
let (incoming, _io) = response_to_wasi(parts, body).expect("conversion ok");
assert_eq!(incoming.status(), hyper::StatusCode::OK);
assert_eq!(
incoming
.headers()
.get("x-echo")
.and_then(|v| v.to_str().ok()),
Some("hi")
);
let body_bytes = http_body_util::BodyExt::collect(incoming.into_body())
.await
.expect("body collect")
.to_bytes();
assert_eq!(&body_bytes[..], b"hello");
}
#[test]
fn builds_default_client() {
let cfg = HttpConfig::default();
let client = ActHttpClient::new(cfg);
assert!(client.is_ok(), "{:?}", client.err());
}
#[test]
fn builds_client_with_keepalive_defaults() {
let cfg = HttpConfig::default();
let client = ActHttpClient::new(cfg);
assert!(client.is_ok(), "{:?}", client.err());
}
#[test]
fn converts_simple_get_request() {
let body: UnsyncBoxBody<bytes::Bytes, _> = Empty::<bytes::Bytes>::new()
.map_err(|_| unreachable!())
.boxed_unsync();
let hyper_req = hyper::Request::builder()
.method(Method::GET)
.uri("https://example.com/foo?bar=baz")
.header("x-custom", "hello")
.body(body)
.expect("hyper request builds");
let (method, url, headers, _body) =
to_request_parts(hyper_req).expect("conversion succeeds");
assert_eq!(method, Method::GET);
assert_eq!(url, "https://example.com/foo?bar=baz");
assert_eq!(
headers.get("x-custom").and_then(|v| v.to_str().ok()),
Some("hello")
);
}
#[test]
fn converts_post_request_with_body_and_port() {
let body_bytes = bytes::Bytes::from_static(b"payload");
let body: WasiBody = http_body_util::Full::new(body_bytes)
.map_err(|_| unreachable!())
.boxed_unsync();
let hyper_req = hyper::Request::builder()
.method(Method::POST)
.uri("http://api.example.com:8080/v1/create")
.header("content-type", "application/json")
.body(body)
.expect("hyper request builds");
let (method, url, headers, _body) =
to_request_parts(hyper_req).expect("conversion succeeds");
assert_eq!(method, Method::POST);
assert_eq!(url, "http://api.example.com:8080/v1/create");
assert_eq!(
headers.get("content-type").and_then(|v| v.to_str().ok()),
Some("application/json")
);
}
#[tokio::test(flavor = "current_thread")]
async fn send_fetches_example_dot_com() {
let body: WasiBody = Empty::<bytes::Bytes>::new()
.map_err(|_| unreachable!())
.boxed_unsync();
let hyper_req = hyper::Request::builder()
.method(Method::GET)
.uri("https://example.com/")
.body(body)
.unwrap();
let cfg = HttpConfig {
mode: act_policy::grant::PolicyMode::Open,
..Default::default()
};
let client = ActHttpClient::new(cfg).expect("client builds");
let options = RequestOptions {
connect_timeout: Some(std::time::Duration::from_secs(10)),
first_byte_timeout: Some(std::time::Duration::from_secs(10)),
between_bytes_timeout: Some(std::time::Duration::from_secs(10)),
};
let (incoming, _io) = client
.send(hyper_req, Some(options))
.await
.expect("send succeeds");
assert_eq!(
incoming.status().as_u16(),
200,
"example.com should return 200"
);
}
#[test]
fn maps_each_error_kind_to_its_wasi_error() {
use hclient::ErrorKind;
let io = || std::io::Error::other("under test");
for (kind, expected) in [
(ErrorKind::Connect, HttpError::ConnectionRefused),
(ErrorKind::Redirect, HttpError::HttpRequestDenied),
] {
let named = format!("{kind:?}");
let mapped = client_error_to_wasi(hclient::Error::new(kind, io()));
assert_eq!(
std::mem::discriminant(&mapped),
std::mem::discriminant(&expected),
"{named} mapped to {mapped:?}"
);
}
let mapped = client_error_to_wasi(hclient::Error::new(ErrorKind::Resolve, io()));
assert!(
matches!(mapped, HttpError::DnsError { rcode: Some(_), .. }),
"a resolve failure must reach the guest as a DNS error naming it, got {mapped:?}"
);
assert!(matches!(
client_error_to_wasi(hclient::Error::new(ErrorKind::Redirect, io())),
HttpError::HttpRequestDenied
));
}
#[tokio::test(flavor = "current_thread")]
async fn redirect_policy_blocks_cross_host_hop() {
use act_policy::Decision;
use act_policy::grant::PolicyMode;
use act_policy::net::{NetworkCheck, NetworkRule, decide};
let allow = vec![NetworkRule {
host: Some("primary.example".into()),
..Default::default()
}];
let deny: Vec<NetworkRule> = vec![];
let blocked = decide(
PolicyMode::Allowlist,
&allow,
&deny,
&NetworkCheck::new("other.example", 443),
);
assert_eq!(blocked, Decision::Deny);
let allowed = decide(
PolicyMode::Allowlist,
&allow,
&deny,
&NetworkCheck::new("primary.example", 443),
);
assert_eq!(allowed, Decision::Allow);
}
#[tokio::test(flavor = "current_thread")]
async fn dns_resolver_filters_denied_cidr() {
use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
use act_policy::net::NetworkRule;
let cfg = HttpConfig {
mode: PolicyMode::Allowlist,
allow: vec![HttpRule {
net: NetworkRule {
host: Some("localhost".into()),
..Default::default()
},
..Default::default()
}],
deny: vec![HttpRule {
net: NetworkRule {
cidr: Some("127.0.0.0/8".into()),
..Default::default()
},
..Default::default()
}],
};
let client = ActHttpClient::new(cfg).expect("client builds");
let body: WasiBody = Empty::<bytes::Bytes>::new()
.map_err(|_| unreachable!())
.boxed_unsync();
let hyper_req = hyper::Request::builder()
.method(Method::GET)
.uri("http://localhost/")
.body(body)
.unwrap();
let options = RequestOptions {
connect_timeout: Some(std::time::Duration::from_secs(5)),
first_byte_timeout: Some(std::time::Duration::from_secs(5)),
between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
};
let err = match client.send(hyper_req, Some(options)).await {
Ok(_) => panic!("localhost resolves into denied 127/8, should fail"),
Err(e) => e,
};
assert!(
matches!(err, HttpError::DnsError { .. })
|| matches!(err, HttpError::ConnectionRefused),
"expected DnsError or ConnectionRefused, got {err:?}"
);
}
#[test]
fn svcb_address_hints_are_filtered_like_any_other_address() {
use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
use act_policy::net::NetworkRule;
let cfg = HttpConfig {
mode: PolicyMode::Allowlist,
allow: vec![HttpRule {
net: NetworkRule {
cidr: Some("10.0.0.0/8".into()),
..Default::default()
},
..Default::default()
}],
deny: vec![],
};
let r = PolicyDnsResolver::new(&cfg);
let inside: std::net::Ipv4Addr = "10.1.2.3".parse().unwrap();
let outside: std::net::Ipv4Addr = "93.184.216.34".parse().unwrap();
assert!(r.keeps_record(
"example.com",
&hclient_dns::Record::new(hclient_dns::RData::A(inside))
));
assert!(!r.keeps_record(
"example.com",
&hclient_dns::Record::new(hclient_dns::RData::A(outside))
));
let mut ep = hclient_dns::SvcbEndpoint::new(1, "example.com".into());
ep.ipv4hint = vec![outside];
assert!(
!r.keeps_record(
"example.com",
&hclient_dns::Record::new(hclient_dns::RData::Https(ep))
),
"an HTTPS record's ipv4hint is an address the connector can dial, \
so it must meet the same rule an A record does"
);
let mut ep_ok = hclient_dns::SvcbEndpoint::new(1, "example.com".into());
ep_ok.ipv4hint = vec![inside];
assert!(
r.keeps_record(
"example.com",
&hclient_dns::Record::new(hclient_dns::RData::Https(ep_ok))
),
"a hint inside the allowed CIDR must pass — this is a filter, not \
a blanket refusal of HTTPS records"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "network: resolves example.com through the system resolver"]
async fn dns_resolver_requires_allow_cidr_match_for_hostnames() {
use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
use act_policy::net::NetworkRule;
let cfg = HttpConfig {
mode: PolicyMode::Allowlist,
allow: vec![HttpRule {
net: NetworkRule {
cidr: Some("10.0.0.0/8".into()),
..Default::default()
},
..Default::default()
}],
deny: vec![],
};
let client = ActHttpClient::new(cfg).expect("client builds");
let body: WasiBody = Empty::<bytes::Bytes>::new()
.map_err(|_| unreachable!())
.boxed_unsync();
let hyper_req = hyper::Request::builder()
.method(Method::GET)
.uri("https://example.com/")
.body(body)
.unwrap();
let options = RequestOptions {
connect_timeout: Some(std::time::Duration::from_secs(5)),
first_byte_timeout: Some(std::time::Duration::from_secs(5)),
between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
};
let err = match client.send(hyper_req, Some(options)).await {
Ok(_) => panic!("example.com IPs not in 10/8, must fail at DNS"),
Err(e) => e,
};
assert!(
matches!(err, HttpError::DnsError { .. }),
"expected DnsError, got {err:?}"
);
assert!(
client.resolver.filtered_everything("example.com"),
"the DnsError must come from policy refusing every address, not \
from a resolver that never answered — this test needs DNS"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "network: makes a real HTTPS request to example.com"]
async fn dns_resolver_host_match_bypasses_allow_cidr() {
use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
use act_policy::net::NetworkRule;
let cfg = HttpConfig {
mode: PolicyMode::Allowlist,
allow: vec![
HttpRule {
net: NetworkRule {
host: Some("example.com".into()),
..Default::default()
},
..Default::default()
},
HttpRule {
net: NetworkRule {
cidr: Some("10.0.0.0/8".into()),
..Default::default()
},
..Default::default()
},
],
deny: vec![],
};
let client = ActHttpClient::new(cfg).expect("client builds");
let body: WasiBody = Empty::<bytes::Bytes>::new()
.map_err(|_| unreachable!())
.boxed_unsync();
let hyper_req = hyper::Request::builder()
.method(Method::GET)
.uri("https://example.com/")
.body(body)
.unwrap();
let options = RequestOptions {
connect_timeout: Some(std::time::Duration::from_secs(10)),
first_byte_timeout: Some(std::time::Duration::from_secs(10)),
between_bytes_timeout: Some(std::time::Duration::from_secs(10)),
};
let (incoming, _io) = client
.send(hyper_req, Some(options))
.await
.expect("example.com allowed via host rule");
assert_eq!(incoming.status().as_u16(), 200);
}
#[derive(Clone, Default)]
struct CapturingWriter(Arc<Mutex<Vec<String>>>);
impl crate::audit::layer::AuditWriter for CapturingWriter {
fn write_line(&self, line: &str) {
self.0.lock().unwrap().push(line.to_string());
}
}
#[tokio::test(flavor = "current_thread")]
async fn redirect_hop_denial_is_audited() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing_subscriber::prelude::*;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback");
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf).await; let resp = b"HTTP/1.1 302 Found\r\n\
Location: http://blocked.example/\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\r\n";
let _ = stream.write_all(resp).await;
let _ = stream.shutdown().await;
});
let cfg = HttpConfig {
mode: PolicyMode::Allowlist,
allow: vec![act_policy::grant::HttpRule {
net: NetworkRule {
host: Some("127.0.0.1".into()),
..Default::default()
},
..Default::default()
}],
deny: vec![],
};
act_store::fetch::install_crypto_provider();
let resolver = PolicyDnsResolver::new(&cfg);
let transport = hclient_native::Native::new(
hclient_rt_tokio::Tokio,
hclient_tls_rustls::Rustls::with_webpki_roots(),
resolver.clone(),
);
let client = hclient::Client::builder(transport)
.redirect(CeilingRedirectPolicy(cfg))
.build()
.expect("client builds");
let writer = CapturingWriter::default();
let sink = writer.0.clone();
let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
writer,
crate::audit::Detail::Rollup,
));
let _guard = tracing::subscriber::set_default(sub);
let result = client.get(format!("http://{addr}/")).send().await;
drop(_guard);
server.await.expect("server task");
let err = result.expect_err("redirect target denied, the request must fail");
assert!(
matches!(err.kind(), hclient::ErrorKind::Redirect),
"expected a redirect-class error, got {err:?}"
);
let lines = sink.lock().unwrap().clone();
let deny_line = lines
.iter()
.find(|l| l.contains("blocked.example"))
.unwrap_or_else(|| panic!("no redirect-deny audit line, got {lines:?}"));
assert!(deny_line.contains("wasi:http"), "got {deny_line}");
assert!(
deny_line.contains("redirect target outside ceiling"),
"reason must distinguish this from an ordinary ceiling denial, got {deny_line}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn dns_cidr_filtered_resolution_is_audited() {
use act_policy::grant::{HttpConfig as PolicyHttpConfig, HttpRule};
use act_policy::net::NetworkRule as PolicyNetworkRule;
use tracing_subscriber::prelude::*;
let cfg = PolicyHttpConfig {
mode: PolicyMode::Allowlist,
allow: vec![HttpRule {
net: PolicyNetworkRule {
host: Some("localhost".into()),
..Default::default()
},
..Default::default()
}],
deny: vec![
HttpRule {
net: PolicyNetworkRule {
cidr: Some("127.0.0.0/8".into()),
..Default::default()
},
..Default::default()
},
HttpRule {
net: PolicyNetworkRule {
cidr: Some("::1/128".into()),
..Default::default()
},
..Default::default()
},
],
};
let client = ActHttpClient::new(cfg).expect("client builds");
let body: WasiBody = Empty::<bytes::Bytes>::new()
.map_err(|_| unreachable!())
.boxed_unsync();
let hyper_req = hyper::Request::builder()
.method(Method::GET)
.uri("http://localhost/")
.body(body)
.unwrap();
let options = RequestOptions {
connect_timeout: Some(std::time::Duration::from_secs(5)),
first_byte_timeout: Some(std::time::Duration::from_secs(5)),
between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
};
let writer = CapturingWriter::default();
let sink = writer.0.clone();
let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
writer,
crate::audit::Detail::Rollup,
));
let _guard = tracing::subscriber::set_default(sub);
let err = match client.send(hyper_req, Some(options)).await {
Ok(_) => panic!("both loopback families are denied, must fail at DNS"),
Err(e) => e,
};
drop(_guard);
assert!(
matches!(err, HttpError::DnsError { .. }),
"expected DnsError, got {err:?}"
);
let lines = sink.lock().unwrap().clone();
let deny_line = lines
.iter()
.find(|l| l.contains("localhost"))
.unwrap_or_else(|| panic!("no dns-filtered deny audit line, got {lines:?}"));
assert!(deny_line.contains("wasi:http"), "got {deny_line}");
assert!(
deny_line.contains("all resolved addresses filtered by CIDR rule"),
"reason must distinguish this from an ordinary ceiling denial, got {deny_line}"
);
}
}