1use std::future::Future;
19use std::pin::Pin;
20use std::sync::Arc;
21
22use http_body_util::BodyExt;
23use wasmtime_wasi_http::{Error as HttpError, RequestOptions, WasiBody};
24
25use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
26use act_policy::grant::{HttpConfig, PolicyMode};
27use act_policy::net::{self as network, NetworkRule};
28
29#[derive(Clone)]
56struct PolicyDnsResolver {
57 inner: Arc<hclient_dns_system::SystemDns<hclient_rt_tokio::Tokio>>,
58 seen: Arc<std::sync::Mutex<std::collections::HashMap<String, (usize, usize)>>>,
64 allow_nets: Arc<Vec<NetworkRule>>,
65 deny_nets: Arc<Vec<NetworkRule>>,
66 mode: PolicyMode,
67}
68
69impl PolicyDnsResolver {
70 fn new(cfg: &HttpConfig) -> Self {
71 Self {
72 inner: Arc::new(hclient_dns_system::SystemDns::new(hclient_rt_tokio::Tokio)),
73 seen: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
74 allow_nets: Arc::new(cfg.allow.iter().map(|r| r.net.clone()).collect()),
75 deny_nets: Arc::new(cfg.deny.iter().map(|r| r.net.clone()).collect()),
76 mode: cfg.mode,
77 }
78 }
79
80 fn filtered_everything(&self, host: &str) -> bool {
92 self.seen
93 .lock()
94 .unwrap_or_else(std::sync::PoisonError::into_inner)
95 .get(host)
96 .is_some_and(|(offered, kept)| *offered > 0 && *kept == 0)
97 }
98
99 fn permits(&self, host: &str, addr: std::net::IpAddr) -> bool {
100 if network::any_deny_cidr_matches(&self.deny_nets, addr, 0) {
101 return false;
102 }
103 let host_allowed = self.allow_nets.iter().any(|r| {
104 r.host
105 .as_deref()
106 .is_some_and(|pat| network::host_matches(pat, host))
107 });
108 let require_allow_cidr = self.mode == PolicyMode::Allowlist
109 && !host_allowed
110 && self.allow_nets.iter().any(|r| r.cidr.is_some());
111 if require_allow_cidr {
112 return self.allow_nets.iter().any(|r| {
113 r.cidr
114 .as_deref()
115 .is_some_and(|c| network::cidr_contains(c, addr))
116 });
117 }
118 true
119 }
120}
121
122impl hclient_dns::Resolve for PolicyDnsResolver {
123 type Records<'a> =
124 futures_util::stream::BoxStream<'a, Result<hclient_dns::Record, hclient::Error>>;
125
126 fn supports(&self, rtype: u16) -> bool {
131 hclient_dns::Resolve::supports(&*self.inner, rtype)
132 }
133
134 fn lookup<'a>(&'a self, name: &str, rtype: u16) -> Self::Records<'a> {
135 self.filtered(name, rtype)
136 }
137}
138
139impl PolicyDnsResolver {
140 fn filtered<'a>(
141 &'a self,
142 name: &str,
143 rtype: u16,
144 ) -> futures_util::stream::BoxStream<'a, Result<hclient_dns::Record, hclient::Error>> {
145 use futures_util::StreamExt;
146 let host = name.to_string();
147 let upstream: futures_util::stream::BoxStream<'a, _> =
148 Box::pin(hclient_dns::Resolve::lookup(&*self.inner, name, rtype));
149 Box::pin(upstream.filter(move |item| {
150 let keep = match item {
151 Ok(record) => match record.rdata {
152 hclient_dns::RData::A(v4) => self.permits(&host, v4.into()),
153 hclient_dns::RData::Aaaa(v6) => self.permits(&host, v6.into()),
154 hclient_dns::RData::Https(ref ep) => {
167 ep.ipv4hint
168 .iter()
169 .all(|v4| self.permits(&host, (*v4).into()))
170 && ep
171 .ipv6hint
172 .iter()
173 .all(|v6| self.permits(&host, (*v6).into()))
174 }
175 _ => true,
181 },
182 Err(_) => true,
187 };
188 let is_address = matches!(
195 item,
196 Ok(hclient_dns::Record {
197 rdata: hclient_dns::RData::A(_) | hclient_dns::RData::Aaaa(_),
198 ..
199 })
200 );
201 if is_address {
202 let mut seen = self
203 .seen
204 .lock()
205 .unwrap_or_else(std::sync::PoisonError::into_inner);
206 let counts = seen.entry(host.clone()).or_insert((0, 0));
207 counts.0 += 1;
208 if keep {
209 counts.1 += 1;
210 }
211 }
212 if !keep {
213 tracing::debug!(%host, "http policy dropped a resolved address");
214 }
215 std::future::ready(keep)
216 }))
217 }
218}
219
220fn redirect_verdict(
232 cfg: &HttpConfig,
233 hop: &hclient::redirect::ProposedRedirect<'_>,
234) -> hclient::redirect::RedirectVerdict {
235 use hclient::redirect::RedirectVerdict;
236
237 let to = hop.to();
238 let host = to.host().unwrap_or("");
239 let scheme = to.scheme_str().unwrap_or("http");
240 let port = to
241 .port_u16()
242 .unwrap_or(if scheme == "https" { 443 } else { 80 });
243
244 let allow_nets: Vec<NetworkRule> = cfg.allow.iter().map(|r| r.net.clone()).collect();
245 let deny_nets: Vec<NetworkRule> = cfg.deny.iter().map(|r| r.net.clone()).collect();
246 let decision = network::decide(
247 cfg.mode,
248 &allow_nets,
249 &deny_nets,
250 &network::NetworkCheck::new(host, port),
251 );
252 #[allow(clippy::match_same_arms)]
255 match decision {
256 act_policy::Decision::Allow => RedirectVerdict::follow(),
257 act_policy::Decision::Ask => RedirectVerdict::follow(),
262 act_policy::Decision::Deny => {
263 tracing::warn!(%to, "http policy: redirect hop blocked");
264 emit_cap_decision(&CapDecisionRecord::statik_with_reason(
265 act_types::constants::CAP_HTTP,
266 &format!("{host}:{port}"),
267 "",
268 Decision4::Deny,
269 &cfg.mode.to_string(),
270 None,
271 Some("redirect target outside ceiling"),
272 ));
273 RedirectVerdict::Refuse("redirect target outside the component's http ceiling")
282 }
283 }
284}
285
286#[derive(Debug)]
295struct CeilingRedirectPolicy(HttpConfig);
296
297impl hclient::redirect::RedirectPolicy for CeilingRedirectPolicy {
298 fn follow(
299 &self,
300 hop: &hclient::redirect::ProposedRedirect<'_>,
301 ) -> hclient::redirect::RedirectVerdict {
302 redirect_verdict(&self.0, hop)
303 }
304}
305
306#[derive(Clone)]
315pub struct ActHttpClient {
316 client: Arc<hclient::Client>,
317 resolver: PolicyDnsResolver,
318 mode: PolicyMode,
319}
320
321impl ActHttpClient {
322 pub fn new(cfg: HttpConfig) -> anyhow::Result<Self> {
323 let cfg_for_hops = cfg.clone();
324
325 act_store::fetch::install_crypto_provider();
326 let resolver = PolicyDnsResolver::new(&cfg);
327 let mode = cfg.mode;
328 let transport = hclient_native::Native::new(
329 hclient_rt_tokio::Tokio,
330 hclient_tls_rustls::Rustls::with_webpki_roots(),
331 resolver.clone(),
332 )
333 .h2_keep_alive(hclient_native::H2KeepAlive::new(
340 std::time::Duration::from_secs(30),
341 std::time::Duration::from_secs(10),
342 ))
343 .pool(hclient_native::PoolConfig {
346 idle_timeout: std::time::Duration::from_secs(600),
347 ..Default::default()
348 });
349
350 let client = hclient::Client::builder(transport)
351 .redirect(CeilingRedirectPolicy(cfg_for_hops))
352 .build()
353 .map_err(|e| anyhow::anyhow!("the HTTP backend cannot serve this policy: {e}"))?;
354 Ok(Self {
355 client: Arc::new(client),
356 resolver,
357 mode,
358 })
359 }
360
361 pub async fn send(
369 &self,
370 request: http::Request<WasiBody>,
371 options: Option<RequestOptions>,
372 ) -> Result<
373 (
374 http::Response<WasiBody>,
375 Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>>,
376 ),
377 HttpError,
378 > {
379 const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
380 let deadline = options
381 .and_then(|o| o.connect_timeout)
382 .unwrap_or(DEFAULT_TIMEOUT)
383 + options
384 .and_then(|o| o.first_byte_timeout)
385 .unwrap_or(DEFAULT_TIMEOUT);
386
387 let (method, url, headers, body) = to_request_parts(request)?;
388 let host = url
390 .parse::<http::Uri>()
391 .ok()
392 .and_then(|u| u.host().map(str::to_string))
393 .unwrap_or_default();
394 let mut req = self.client.request(method, &url);
395 for (name, value) in &headers {
396 req = req.header(name.as_str(), value.to_str().unwrap_or_default());
397 }
398 let resp = match tokio::time::timeout(deadline, req.body(body).send()).await {
399 Err(_) => return Err(HttpError::ConnectionTimeout),
400 Ok(Err(e)) => {
401 if matches!(e.kind(), hclient::ErrorKind::Resolve)
406 && self.resolver.filtered_everything(&host)
407 {
408 emit_cap_decision(&CapDecisionRecord::statik_with_reason(
409 act_types::constants::CAP_HTTP,
410 &host,
411 "",
412 Decision4::Deny,
413 &self.mode.to_string(),
414 None,
415 Some("all resolved addresses filtered by CIDR rule"),
416 ));
417 }
418 return Err(client_error_to_wasi(e));
419 }
420 Ok(Ok(resp)) => resp,
421 };
422 let (parts, body) = resp.into_parts();
423 response_to_wasi(parts, body)
424 }
425}
426
427#[allow(clippy::type_complexity)]
429fn to_request_parts(
430 request: http::Request<WasiBody>,
431) -> Result<(http::Method, String, http::HeaderMap, hclient::RequestBody), HttpError> {
432 let (parts, body) = request.into_parts();
433 let scheme = parts
434 .uri
435 .scheme_str()
436 .map_or_else(|| "https".into(), str::to_string);
437 let authority = parts
438 .uri
439 .authority()
440 .map(std::string::ToString::to_string)
441 .ok_or(HttpError::HttpRequestUriInvalid)?;
442 let path_and_query = parts
443 .uri
444 .path_and_query()
445 .map_or("/", http::uri::PathAndQuery::as_str);
446 let url = format!("{scheme}://{authority}{path_and_query}");
447
448 let body = hclient::RequestBody::Streaming(Box::new(WasiRequestBody(body)));
455 Ok((parts.method, url, parts.headers, body))
456}
457
458struct WasiRequestBody(WasiBody);
464
465impl http_body::Body for WasiRequestBody {
466 type Data = bytes::Bytes;
467 type Error = hclient::Error;
468
469 fn poll_frame(
470 self: Pin<&mut Self>,
471 cx: &mut std::task::Context<'_>,
472 ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
473 let inner = unsafe { self.map_unchecked_mut(|s| &mut s.0) };
474 inner.poll_frame(cx).map(|opt| {
475 opt.map(|res| {
476 res.map_err(|_| {
477 hclient::Error::new(
478 hclient::ErrorKind::Body,
479 std::io::Error::other("wasi:http body stream error"),
480 )
481 })
482 })
483 })
484 }
485}
486
487fn client_error_to_wasi(err: hclient::Error) -> HttpError {
494 use hclient::ErrorKind;
495 match err.kind() {
496 ErrorKind::Timeout(_) => HttpError::ConnectionTimeout,
497 ErrorKind::Resolve => HttpError::DnsError {
498 rcode: Some(err.to_string()),
499 info_code: None,
500 },
501 ErrorKind::Connect => HttpError::ConnectionRefused,
502 ErrorKind::Redirect => HttpError::HttpRequestDenied,
506 ErrorKind::Body => HttpError::HttpRequestBodySize(None),
507 ErrorKind::Decode => HttpError::HttpProtocolError,
511 _ => HttpError::HttpProtocolError,
512 }
513}
514
515type HookResponse = (
527 http::Response<WasiBody>,
528 Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>>,
529);
530
531fn response_to_wasi<B>(parts: http::response::Parts, body: B) -> Result<HookResponse, HttpError>
532where
533 B: http_body::Body<Data = bytes::Bytes, Error = hclient::Error> + Send + 'static,
534{
535 let mut headers = parts.headers.clone();
536 headers.remove(http::header::TRANSFER_ENCODING);
540 headers.remove(http::header::CONTENT_LENGTH);
541
542 let body: WasiBody = BodyExt::boxed_unsync(BodyExt::map_err(body, client_error_to_wasi));
543
544 let mut builder = http::Response::builder().status(parts.status);
545 if let Some(hdrs) = builder.headers_mut() {
546 hdrs.extend(headers);
547 }
548 let resp = builder
549 .body(body)
550 .map_err(|_| HttpError::HttpProtocolError)?;
551 let io: Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>> =
552 Box::pin(async { Ok(()) });
553 Ok((resp, io))
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use act_policy::grant::HttpConfig;
560 use http::Method;
561 use http_body_util::combinators::UnsyncBoxBody;
562 use http_body_util::{BodyExt, Empty};
563 use std::sync::Mutex;
564
565 #[tokio::test(flavor = "current_thread")]
566 async fn converts_response_status_headers_body() {
567 let http_resp = http::Response::builder()
572 .status(200)
573 .header("x-echo", "hi")
574 .body(
575 http_body_util::Full::new(bytes::Bytes::from_static(b"hello"))
576 .map_err(|_: std::convert::Infallible| unreachable!())
577 .boxed_unsync(),
578 )
579 .unwrap();
580 let (parts, body) = http_resp.into_parts();
581 let body = BodyExt::map_err(body, |_| {
582 hclient::Error::new(hclient::ErrorKind::Body, std::io::Error::other("unused"))
583 });
584
585 let (incoming, _io) = response_to_wasi(parts, body).expect("conversion ok");
586
587 assert_eq!(incoming.status(), hyper::StatusCode::OK);
588 assert_eq!(
589 incoming
590 .headers()
591 .get("x-echo")
592 .and_then(|v| v.to_str().ok()),
593 Some("hi")
594 );
595 let body_bytes = http_body_util::BodyExt::collect(incoming.into_body())
596 .await
597 .expect("body collect")
598 .to_bytes();
599 assert_eq!(&body_bytes[..], b"hello");
600 }
601
602 #[test]
603 fn builds_default_client() {
604 let cfg = HttpConfig::default();
605 let client = ActHttpClient::new(cfg);
606 assert!(client.is_ok(), "{:?}", client.err());
607 }
608
609 #[test]
610 fn builds_client_with_keepalive_defaults() {
611 let cfg = HttpConfig::default();
616 let client = ActHttpClient::new(cfg);
617 assert!(client.is_ok(), "{:?}", client.err());
618 }
619
620 #[test]
621 fn converts_simple_get_request() {
622 let body: UnsyncBoxBody<bytes::Bytes, _> = Empty::<bytes::Bytes>::new()
623 .map_err(|_| unreachable!())
624 .boxed_unsync();
625 let hyper_req = hyper::Request::builder()
626 .method(Method::GET)
627 .uri("https://example.com/foo?bar=baz")
628 .header("x-custom", "hello")
629 .body(body)
630 .expect("hyper request builds");
631
632 let (method, url, headers, _body) =
633 to_request_parts(hyper_req).expect("conversion succeeds");
634
635 assert_eq!(method, Method::GET);
636 assert_eq!(url, "https://example.com/foo?bar=baz");
637 assert_eq!(
638 headers.get("x-custom").and_then(|v| v.to_str().ok()),
639 Some("hello")
640 );
641 }
642
643 #[test]
644 fn converts_post_request_with_body_and_port() {
645 let body_bytes = bytes::Bytes::from_static(b"payload");
646 let body: WasiBody = http_body_util::Full::new(body_bytes)
647 .map_err(|_| unreachable!())
648 .boxed_unsync();
649 let hyper_req = hyper::Request::builder()
650 .method(Method::POST)
651 .uri("http://api.example.com:8080/v1/create")
652 .header("content-type", "application/json")
653 .body(body)
654 .expect("hyper request builds");
655
656 let (method, url, headers, _body) =
657 to_request_parts(hyper_req).expect("conversion succeeds");
658
659 assert_eq!(method, Method::POST);
660 assert_eq!(url, "http://api.example.com:8080/v1/create");
661 assert_eq!(
662 headers.get("content-type").and_then(|v| v.to_str().ok()),
663 Some("application/json")
664 );
665 }
666
667 #[tokio::test(flavor = "current_thread")]
668 async fn send_fetches_example_dot_com() {
669 let body: WasiBody = Empty::<bytes::Bytes>::new()
671 .map_err(|_| unreachable!())
672 .boxed_unsync();
673 let hyper_req = hyper::Request::builder()
674 .method(Method::GET)
675 .uri("https://example.com/")
676 .body(body)
677 .unwrap();
678
679 let cfg = HttpConfig {
680 mode: act_policy::grant::PolicyMode::Open,
681 ..Default::default()
682 };
683 let client = ActHttpClient::new(cfg).expect("client builds");
684 let options = RequestOptions {
685 connect_timeout: Some(std::time::Duration::from_secs(10)),
686 first_byte_timeout: Some(std::time::Duration::from_secs(10)),
687 between_bytes_timeout: Some(std::time::Duration::from_secs(10)),
688 };
689 let (incoming, _io) = client
690 .send(hyper_req, Some(options))
691 .await
692 .expect("send succeeds");
693 assert_eq!(
694 incoming.status().as_u16(),
695 200,
696 "example.com should return 200"
697 );
698 }
699
700 #[test]
708 fn maps_each_error_kind_to_its_wasi_error() {
709 use hclient::ErrorKind;
710 let io = || std::io::Error::other("under test");
711
712 for (kind, expected) in [
713 (ErrorKind::Connect, HttpError::ConnectionRefused),
714 (ErrorKind::Redirect, HttpError::HttpRequestDenied),
715 ] {
716 let named = format!("{kind:?}");
717 let mapped = client_error_to_wasi(hclient::Error::new(kind, io()));
718 assert_eq!(
719 std::mem::discriminant(&mapped),
720 std::mem::discriminant(&expected),
721 "{named} mapped to {mapped:?}"
722 );
723 }
724
725 let mapped = client_error_to_wasi(hclient::Error::new(ErrorKind::Resolve, io()));
728 assert!(
729 matches!(mapped, HttpError::DnsError { rcode: Some(_), .. }),
730 "a resolve failure must reach the guest as a DNS error naming it, got {mapped:?}"
731 );
732
733 assert!(matches!(
736 client_error_to_wasi(hclient::Error::new(ErrorKind::Redirect, io())),
737 HttpError::HttpRequestDenied
738 ));
739 }
740
741 #[tokio::test(flavor = "current_thread")]
742 async fn redirect_policy_blocks_cross_host_hop() {
743 use act_policy::Decision;
744 use act_policy::grant::PolicyMode;
745 use act_policy::net::{NetworkCheck, NetworkRule, decide};
746
747 let allow = vec![NetworkRule {
748 host: Some("primary.example".into()),
749 ..Default::default()
750 }];
751 let deny: Vec<NetworkRule> = vec![];
752
753 let blocked = decide(
754 PolicyMode::Allowlist,
755 &allow,
756 &deny,
757 &NetworkCheck::new("other.example", 443),
758 );
759 assert_eq!(blocked, Decision::Deny);
760
761 let allowed = decide(
762 PolicyMode::Allowlist,
763 &allow,
764 &deny,
765 &NetworkCheck::new("primary.example", 443),
766 );
767 assert_eq!(allowed, Decision::Allow);
768 }
769
770 #[tokio::test(flavor = "current_thread")]
771 async fn dns_resolver_filters_denied_cidr() {
772 use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
773 use act_policy::net::NetworkRule;
774
775 let cfg = HttpConfig {
776 mode: PolicyMode::Allowlist,
777 allow: vec![HttpRule {
778 net: NetworkRule {
779 host: Some("localhost".into()),
780 ..Default::default()
781 },
782 ..Default::default()
783 }],
784 deny: vec![HttpRule {
786 net: NetworkRule {
787 cidr: Some("127.0.0.0/8".into()),
788 ..Default::default()
789 },
790 ..Default::default()
791 }],
792 };
793 let client = ActHttpClient::new(cfg).expect("client builds");
794 let body: WasiBody = Empty::<bytes::Bytes>::new()
795 .map_err(|_| unreachable!())
796 .boxed_unsync();
797 let hyper_req = hyper::Request::builder()
798 .method(Method::GET)
799 .uri("http://localhost/")
800 .body(body)
801 .unwrap();
802 let options = RequestOptions {
803 connect_timeout: Some(std::time::Duration::from_secs(5)),
804 first_byte_timeout: Some(std::time::Duration::from_secs(5)),
805 between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
806 };
807 let err = match client.send(hyper_req, Some(options)).await {
808 Ok(_) => panic!("localhost resolves into denied 127/8, should fail"),
809 Err(e) => e,
810 };
811 assert!(
815 matches!(err, HttpError::DnsError { .. })
816 || matches!(err, HttpError::ConnectionRefused),
817 "expected DnsError or ConnectionRefused, got {err:?}"
818 );
819 }
820
821 #[tokio::test(flavor = "current_thread")]
822 #[ignore = "network: resolves example.com through the system resolver"]
830 async fn dns_resolver_requires_allow_cidr_match_for_hostnames() {
831 use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
834 use act_policy::net::NetworkRule;
835
836 let cfg = HttpConfig {
837 mode: PolicyMode::Allowlist,
838 allow: vec![HttpRule {
840 net: NetworkRule {
841 cidr: Some("10.0.0.0/8".into()),
842 ..Default::default()
843 },
844 ..Default::default()
845 }],
846 deny: vec![],
847 };
848 let client = ActHttpClient::new(cfg).expect("client builds");
849 let body: WasiBody = Empty::<bytes::Bytes>::new()
850 .map_err(|_| unreachable!())
851 .boxed_unsync();
852 let hyper_req = hyper::Request::builder()
853 .method(Method::GET)
854 .uri("https://example.com/")
855 .body(body)
856 .unwrap();
857 let options = RequestOptions {
858 connect_timeout: Some(std::time::Duration::from_secs(5)),
859 first_byte_timeout: Some(std::time::Duration::from_secs(5)),
860 between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
861 };
862 let err = match client.send(hyper_req, Some(options)).await {
863 Ok(_) => panic!("example.com IPs not in 10/8, must fail at DNS"),
864 Err(e) => e,
865 };
866 assert!(
867 matches!(err, HttpError::DnsError { .. }),
868 "expected DnsError, got {err:?}"
869 );
870 assert!(
878 client.resolver.filtered_everything("example.com"),
879 "the DnsError must come from policy refusing every address, not \
880 from a resolver that never answered — this test needs DNS"
881 );
882 }
883
884 #[tokio::test(flavor = "current_thread")]
885 #[ignore = "network: makes a real HTTPS request to example.com"]
886 async fn dns_resolver_host_match_bypasses_allow_cidr() {
887 use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
891 use act_policy::net::NetworkRule;
892
893 let cfg = HttpConfig {
894 mode: PolicyMode::Allowlist,
895 allow: vec![
896 HttpRule {
897 net: NetworkRule {
898 host: Some("example.com".into()),
899 ..Default::default()
900 },
901 ..Default::default()
902 },
903 HttpRule {
904 net: NetworkRule {
905 cidr: Some("10.0.0.0/8".into()),
906 ..Default::default()
907 },
908 ..Default::default()
909 },
910 ],
911 deny: vec![],
912 };
913 let client = ActHttpClient::new(cfg).expect("client builds");
914 let body: WasiBody = Empty::<bytes::Bytes>::new()
915 .map_err(|_| unreachable!())
916 .boxed_unsync();
917 let hyper_req = hyper::Request::builder()
918 .method(Method::GET)
919 .uri("https://example.com/")
920 .body(body)
921 .unwrap();
922 let options = RequestOptions {
923 connect_timeout: Some(std::time::Duration::from_secs(10)),
924 first_byte_timeout: Some(std::time::Duration::from_secs(10)),
925 between_bytes_timeout: Some(std::time::Duration::from_secs(10)),
926 };
927 let (incoming, _io) = client
928 .send(hyper_req, Some(options))
929 .await
930 .expect("example.com allowed via host rule");
931 assert_eq!(incoming.status().as_u16(), 200);
932 }
933
934 #[derive(Clone, Default)]
939 struct CapturingWriter(Arc<Mutex<Vec<String>>>);
940 impl crate::audit::layer::AuditWriter for CapturingWriter {
941 fn write_line(&self, line: &str) {
942 self.0.lock().unwrap().push(line.to_string());
943 }
944 }
945
946 #[tokio::test(flavor = "current_thread")]
968 async fn redirect_hop_denial_is_audited() {
969 use tokio::io::{AsyncReadExt, AsyncWriteExt};
970 use tracing_subscriber::prelude::*;
971
972 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
973 .await
974 .expect("bind loopback");
975 let addr = listener.local_addr().unwrap();
976 let server = tokio::spawn(async move {
977 let (mut stream, _) = listener.accept().await.expect("accept");
978 let mut buf = [0u8; 1024];
979 let _ = stream.read(&mut buf).await; let resp = b"HTTP/1.1 302 Found\r\n\
981 Location: http://blocked.example/\r\n\
982 Content-Length: 0\r\n\
983 Connection: close\r\n\r\n";
984 let _ = stream.write_all(resp).await;
985 let _ = stream.shutdown().await;
986 });
987
988 let cfg = HttpConfig {
992 mode: PolicyMode::Allowlist,
993 allow: vec![act_policy::grant::HttpRule {
994 net: NetworkRule {
995 host: Some("127.0.0.1".into()),
996 ..Default::default()
997 },
998 ..Default::default()
999 }],
1000 deny: vec![],
1001 };
1002 act_store::fetch::install_crypto_provider();
1003 let resolver = PolicyDnsResolver::new(&cfg);
1004 let transport = hclient_native::Native::new(
1005 hclient_rt_tokio::Tokio,
1006 hclient_tls_rustls::Rustls::with_webpki_roots(),
1007 resolver.clone(),
1008 );
1009 let client = hclient::Client::builder(transport)
1010 .redirect(CeilingRedirectPolicy(cfg))
1011 .build()
1012 .expect("client builds");
1013
1014 let writer = CapturingWriter::default();
1015 let sink = writer.0.clone();
1016 let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
1017 writer,
1018 crate::audit::Detail::Rollup,
1019 ));
1020 let _guard = tracing::subscriber::set_default(sub);
1021
1022 let result = client.get(format!("http://{addr}/")).send().await;
1023
1024 drop(_guard);
1025 server.await.expect("server task");
1026
1027 let err = result.expect_err("redirect target denied, the request must fail");
1028 assert!(
1029 matches!(err.kind(), hclient::ErrorKind::Redirect),
1030 "expected a redirect-class error, got {err:?}"
1031 );
1032
1033 let lines = sink.lock().unwrap().clone();
1034 let deny_line = lines
1035 .iter()
1036 .find(|l| l.contains("blocked.example"))
1037 .unwrap_or_else(|| panic!("no redirect-deny audit line, got {lines:?}"));
1038 assert!(deny_line.contains("wasi:http"), "got {deny_line}");
1039 assert!(
1040 deny_line.contains("redirect target outside ceiling"),
1041 "reason must distinguish this from an ordinary ceiling denial, got {deny_line}"
1042 );
1043 }
1044
1045 #[tokio::test(flavor = "current_thread")]
1058 async fn dns_cidr_filtered_resolution_is_audited() {
1059 use act_policy::grant::{HttpConfig as PolicyHttpConfig, HttpRule};
1060 use act_policy::net::NetworkRule as PolicyNetworkRule;
1061 use tracing_subscriber::prelude::*;
1062
1063 let cfg = PolicyHttpConfig {
1064 mode: PolicyMode::Allowlist,
1065 allow: vec![HttpRule {
1066 net: PolicyNetworkRule {
1067 host: Some("localhost".into()),
1068 ..Default::default()
1069 },
1070 ..Default::default()
1071 }],
1072 deny: vec![
1073 HttpRule {
1074 net: PolicyNetworkRule {
1075 cidr: Some("127.0.0.0/8".into()),
1076 ..Default::default()
1077 },
1078 ..Default::default()
1079 },
1080 HttpRule {
1081 net: PolicyNetworkRule {
1082 cidr: Some("::1/128".into()),
1083 ..Default::default()
1084 },
1085 ..Default::default()
1086 },
1087 ],
1088 };
1089 let client = ActHttpClient::new(cfg).expect("client builds");
1090 let body: WasiBody = Empty::<bytes::Bytes>::new()
1091 .map_err(|_| unreachable!())
1092 .boxed_unsync();
1093 let hyper_req = hyper::Request::builder()
1094 .method(Method::GET)
1095 .uri("http://localhost/")
1096 .body(body)
1097 .unwrap();
1098 let options = RequestOptions {
1099 connect_timeout: Some(std::time::Duration::from_secs(5)),
1100 first_byte_timeout: Some(std::time::Duration::from_secs(5)),
1101 between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
1102 };
1103
1104 let writer = CapturingWriter::default();
1105 let sink = writer.0.clone();
1106 let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
1107 writer,
1108 crate::audit::Detail::Rollup,
1109 ));
1110 let _guard = tracing::subscriber::set_default(sub);
1111
1112 let err = match client.send(hyper_req, Some(options)).await {
1113 Ok(_) => panic!("both loopback families are denied, must fail at DNS"),
1114 Err(e) => e,
1115 };
1116
1117 drop(_guard);
1118
1119 assert!(
1120 matches!(err, HttpError::DnsError { .. }),
1121 "expected DnsError, got {err:?}"
1122 );
1123
1124 let lines = sink.lock().unwrap().clone();
1125 let deny_line = lines
1126 .iter()
1127 .find(|l| l.contains("localhost"))
1128 .unwrap_or_else(|| panic!("no dns-filtered deny audit line, got {lines:?}"));
1129 assert!(deny_line.contains("wasi:http"), "got {deny_line}");
1130 assert!(
1131 deny_line.contains("all resolved addresses filtered by CIDR rule"),
1132 "reason must distinguish this from an ordinary ceiling denial, got {deny_line}"
1133 );
1134 }
1135}