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 keeps_record(&self, host: &str, record: &hclient_dns::Record) -> bool {
88 match record.rdata {
89 hclient_dns::RData::A(v4) => self.permits(host, v4.into()),
90 hclient_dns::RData::Aaaa(v6) => self.permits(host, v6.into()),
91 hclient_dns::RData::Https(ref ep) => {
101 ep.ipv4hint
102 .iter()
103 .all(|v4| self.permits(host, (*v4).into()))
104 && ep
105 .ipv6hint
106 .iter()
107 .all(|v6| self.permits(host, (*v6).into()))
108 }
109 _ => true,
113 }
114 }
115
116 fn filtered_everything(&self, host: &str) -> bool {
128 self.seen
129 .lock()
130 .unwrap_or_else(std::sync::PoisonError::into_inner)
131 .get(host)
132 .is_some_and(|(offered, kept)| *offered > 0 && *kept == 0)
133 }
134
135 fn permits(&self, host: &str, addr: std::net::IpAddr) -> bool {
136 if network::any_deny_cidr_matches(&self.deny_nets, addr, 0) {
137 return false;
138 }
139 let host_allowed = self.allow_nets.iter().any(|r| {
140 r.host
141 .as_deref()
142 .is_some_and(|pat| network::host_matches(pat, host))
143 });
144 let require_allow_cidr = self.mode == PolicyMode::Allowlist
145 && !host_allowed
146 && self.allow_nets.iter().any(|r| r.cidr.is_some());
147 if require_allow_cidr {
148 return self.allow_nets.iter().any(|r| {
149 r.cidr
150 .as_deref()
151 .is_some_and(|c| network::cidr_contains(c, addr))
152 });
153 }
154 true
155 }
156}
157
158impl hclient_dns::Resolve for PolicyDnsResolver {
159 type Records<'a> =
160 futures_util::stream::BoxStream<'a, Result<hclient_dns::Record, hclient::Error>>;
161
162 fn supports(&self, rtype: u16) -> bool {
167 hclient_dns::Resolve::supports(&*self.inner, rtype)
168 }
169
170 fn lookup<'a>(&'a self, name: &str, rtype: u16) -> Self::Records<'a> {
171 self.filtered(name, rtype)
172 }
173}
174
175impl PolicyDnsResolver {
176 fn filtered<'a>(
177 &'a self,
178 name: &str,
179 rtype: u16,
180 ) -> futures_util::stream::BoxStream<'a, Result<hclient_dns::Record, hclient::Error>> {
181 use futures_util::StreamExt;
182 let host = name.to_string();
183 let upstream: futures_util::stream::BoxStream<'a, _> =
184 Box::pin(hclient_dns::Resolve::lookup(&*self.inner, name, rtype));
185 Box::pin(upstream.filter(move |item| {
186 let keep = match item {
187 Ok(record) => self.keeps_record(&host, record),
188 Err(_) => true,
193 };
194 let is_address = matches!(
201 item,
202 Ok(hclient_dns::Record {
203 rdata: hclient_dns::RData::A(_) | hclient_dns::RData::Aaaa(_),
204 ..
205 })
206 );
207 if is_address {
208 let mut seen = self
209 .seen
210 .lock()
211 .unwrap_or_else(std::sync::PoisonError::into_inner);
212 let counts = seen.entry(host.clone()).or_insert((0, 0));
213 counts.0 += 1;
214 if keep {
215 counts.1 += 1;
216 }
217 }
218 if !keep {
219 tracing::debug!(%host, "http policy dropped a resolved address");
220 }
221 std::future::ready(keep)
222 }))
223 }
224}
225
226fn redirect_verdict(
238 cfg: &HttpConfig,
239 hop: &hclient::redirect::ProposedRedirect<'_>,
240) -> hclient::redirect::RedirectVerdict {
241 use hclient::redirect::RedirectVerdict;
242
243 let to = hop.to();
244 let host = to.host().unwrap_or("");
245 let scheme = to.scheme_str().unwrap_or("http");
246 let port = to
247 .port_u16()
248 .unwrap_or(if scheme == "https" { 443 } else { 80 });
249
250 let allow_nets: Vec<NetworkRule> = cfg.allow.iter().map(|r| r.net.clone()).collect();
251 let deny_nets: Vec<NetworkRule> = cfg.deny.iter().map(|r| r.net.clone()).collect();
252 let decision = network::decide(
253 cfg.mode,
254 &allow_nets,
255 &deny_nets,
256 &network::NetworkCheck::new(host, port),
257 );
258 #[allow(clippy::match_same_arms)]
261 match decision {
262 act_policy::Decision::Allow => RedirectVerdict::follow(),
263 act_policy::Decision::Ask => RedirectVerdict::follow(),
268 act_policy::Decision::Deny => {
269 tracing::warn!(%to, "http policy: redirect hop blocked");
270 emit_cap_decision(&CapDecisionRecord::statik_with_reason(
271 act_types::constants::CAP_HTTP,
272 &format!("{host}:{port}"),
273 "",
274 Decision4::Deny,
275 &cfg.mode.to_string(),
276 None,
277 Some("redirect target outside ceiling"),
278 ));
279 RedirectVerdict::Refuse("redirect target outside the component's http ceiling")
288 }
289 }
290}
291
292#[derive(Debug)]
301struct CeilingRedirectPolicy(HttpConfig);
302
303impl hclient::redirect::RedirectPolicy for CeilingRedirectPolicy {
304 fn follow(
305 &self,
306 hop: &hclient::redirect::ProposedRedirect<'_>,
307 ) -> hclient::redirect::RedirectVerdict {
308 redirect_verdict(&self.0, hop)
309 }
310}
311
312#[derive(Clone)]
321pub struct ActHttpClient {
322 client: Arc<hclient::Client>,
323 resolver: PolicyDnsResolver,
324 mode: PolicyMode,
325}
326
327impl ActHttpClient {
328 pub fn new(cfg: HttpConfig) -> anyhow::Result<Self> {
329 let cfg_for_hops = cfg.clone();
330
331 act_store::fetch::install_crypto_provider();
332 let resolver = PolicyDnsResolver::new(&cfg);
333 let mode = cfg.mode;
334 let transport = hclient_native::Native::new(
335 hclient_rt_tokio::Tokio,
336 hclient_tls_rustls::Rustls::with_webpki_roots(),
337 resolver.clone(),
338 )
339 .h2_keep_alive(hclient_native::H2KeepAlive::new(
346 std::time::Duration::from_secs(30),
347 std::time::Duration::from_secs(10),
348 ))
349 .pool(hclient_native::PoolConfig {
352 idle_timeout: std::time::Duration::from_secs(600),
353 ..Default::default()
354 });
355
356 let client = hclient::Client::builder(transport)
357 .redirect(CeilingRedirectPolicy(cfg_for_hops))
358 .build()
359 .map_err(|e| anyhow::anyhow!("the HTTP backend cannot serve this policy: {e}"))?;
360 Ok(Self {
361 client: Arc::new(client),
362 resolver,
363 mode,
364 })
365 }
366
367 pub async fn send(
375 &self,
376 request: http::Request<WasiBody>,
377 options: Option<RequestOptions>,
378 ) -> Result<
379 (
380 http::Response<WasiBody>,
381 Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>>,
382 ),
383 HttpError,
384 > {
385 const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
386 let deadline = options
387 .and_then(|o| o.connect_timeout)
388 .unwrap_or(DEFAULT_TIMEOUT)
389 + options
390 .and_then(|o| o.first_byte_timeout)
391 .unwrap_or(DEFAULT_TIMEOUT);
392
393 let (method, url, headers, body) = to_request_parts(request)?;
394 let host = url
396 .parse::<http::Uri>()
397 .ok()
398 .and_then(|u| u.host().map(str::to_string))
399 .unwrap_or_default();
400 let mut req = self.client.request(method, &url);
401 for (name, value) in &headers {
402 req = req.header(name.as_str(), value.to_str().unwrap_or_default());
403 }
404 let resp = match tokio::time::timeout(deadline, req.body(body).send()).await {
405 Err(_) => return Err(HttpError::ConnectionTimeout),
406 Ok(Err(e)) => {
407 if matches!(e.kind(), hclient::ErrorKind::Resolve)
412 && self.resolver.filtered_everything(&host)
413 {
414 emit_cap_decision(&CapDecisionRecord::statik_with_reason(
415 act_types::constants::CAP_HTTP,
416 &host,
417 "",
418 Decision4::Deny,
419 &self.mode.to_string(),
420 None,
421 Some("all resolved addresses filtered by CIDR rule"),
422 ));
423 }
424 return Err(client_error_to_wasi(e));
425 }
426 Ok(Ok(resp)) => resp,
427 };
428 let (parts, body) = resp.into_parts();
429 response_to_wasi(parts, body)
430 }
431}
432
433#[allow(clippy::type_complexity)]
435fn to_request_parts(
436 request: http::Request<WasiBody>,
437) -> Result<(http::Method, String, http::HeaderMap, hclient::RequestBody), HttpError> {
438 let (parts, body) = request.into_parts();
439 let scheme = parts
440 .uri
441 .scheme_str()
442 .map_or_else(|| "https".into(), str::to_string);
443 let authority = parts
444 .uri
445 .authority()
446 .map(std::string::ToString::to_string)
447 .ok_or(HttpError::HttpRequestUriInvalid)?;
448 let path_and_query = parts
449 .uri
450 .path_and_query()
451 .map_or("/", http::uri::PathAndQuery::as_str);
452 let url = format!("{scheme}://{authority}{path_and_query}");
453
454 let body = hclient::RequestBody::Streaming(Box::new(WasiRequestBody(body)));
461 Ok((parts.method, url, parts.headers, body))
462}
463
464struct WasiRequestBody(WasiBody);
470
471impl http_body::Body for WasiRequestBody {
472 type Data = bytes::Bytes;
473 type Error = hclient::Error;
474
475 fn poll_frame(
476 self: Pin<&mut Self>,
477 cx: &mut std::task::Context<'_>,
478 ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
479 let inner = unsafe { self.map_unchecked_mut(|s| &mut s.0) };
480 inner.poll_frame(cx).map(|opt| {
481 opt.map(|res| {
482 res.map_err(|_| {
483 hclient::Error::new(
484 hclient::ErrorKind::Body,
485 std::io::Error::other("wasi:http body stream error"),
486 )
487 })
488 })
489 })
490 }
491}
492
493fn client_error_to_wasi(err: hclient::Error) -> HttpError {
500 use hclient::ErrorKind;
501 match err.kind() {
502 ErrorKind::Timeout(_) => HttpError::ConnectionTimeout,
503 ErrorKind::Resolve => HttpError::DnsError {
504 rcode: Some(err.to_string()),
505 info_code: None,
506 },
507 ErrorKind::Connect => HttpError::ConnectionRefused,
508 ErrorKind::Redirect => HttpError::HttpRequestDenied,
512 ErrorKind::Body => HttpError::HttpRequestBodySize(None),
513 ErrorKind::Decode => HttpError::HttpProtocolError,
517 _ => HttpError::HttpProtocolError,
518 }
519}
520
521type HookResponse = (
533 http::Response<WasiBody>,
534 Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>>,
535);
536
537fn response_to_wasi<B>(parts: http::response::Parts, body: B) -> Result<HookResponse, HttpError>
538where
539 B: http_body::Body<Data = bytes::Bytes, Error = hclient::Error> + Send + 'static,
540{
541 let mut headers = parts.headers.clone();
542 headers.remove(http::header::TRANSFER_ENCODING);
546 headers.remove(http::header::CONTENT_LENGTH);
547
548 let body: WasiBody = BodyExt::boxed_unsync(BodyExt::map_err(body, client_error_to_wasi));
549
550 let mut builder = http::Response::builder().status(parts.status);
551 if let Some(hdrs) = builder.headers_mut() {
552 hdrs.extend(headers);
553 }
554 let resp = builder
555 .body(body)
556 .map_err(|_| HttpError::HttpProtocolError)?;
557 let io: Pin<Box<dyn Future<Output = Result<(), HttpError>> + Send>> =
558 Box::pin(async { Ok(()) });
559 Ok((resp, io))
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use act_policy::grant::HttpConfig;
566 use http::Method;
567 use http_body_util::combinators::UnsyncBoxBody;
568 use http_body_util::{BodyExt, Empty};
569 use std::sync::Mutex;
570
571 #[tokio::test(flavor = "current_thread")]
572 async fn converts_response_status_headers_body() {
573 let http_resp = http::Response::builder()
578 .status(200)
579 .header("x-echo", "hi")
580 .body(
581 http_body_util::Full::new(bytes::Bytes::from_static(b"hello"))
582 .map_err(|_: std::convert::Infallible| unreachable!())
583 .boxed_unsync(),
584 )
585 .unwrap();
586 let (parts, body) = http_resp.into_parts();
587 let body = BodyExt::map_err(body, |_| {
588 hclient::Error::new(hclient::ErrorKind::Body, std::io::Error::other("unused"))
589 });
590
591 let (incoming, _io) = response_to_wasi(parts, body).expect("conversion ok");
592
593 assert_eq!(incoming.status(), hyper::StatusCode::OK);
594 assert_eq!(
595 incoming
596 .headers()
597 .get("x-echo")
598 .and_then(|v| v.to_str().ok()),
599 Some("hi")
600 );
601 let body_bytes = http_body_util::BodyExt::collect(incoming.into_body())
602 .await
603 .expect("body collect")
604 .to_bytes();
605 assert_eq!(&body_bytes[..], b"hello");
606 }
607
608 #[test]
609 fn builds_default_client() {
610 let cfg = HttpConfig::default();
611 let client = ActHttpClient::new(cfg);
612 assert!(client.is_ok(), "{:?}", client.err());
613 }
614
615 #[test]
616 fn builds_client_with_keepalive_defaults() {
617 let cfg = HttpConfig::default();
622 let client = ActHttpClient::new(cfg);
623 assert!(client.is_ok(), "{:?}", client.err());
624 }
625
626 #[test]
627 fn converts_simple_get_request() {
628 let body: UnsyncBoxBody<bytes::Bytes, _> = Empty::<bytes::Bytes>::new()
629 .map_err(|_| unreachable!())
630 .boxed_unsync();
631 let hyper_req = hyper::Request::builder()
632 .method(Method::GET)
633 .uri("https://example.com/foo?bar=baz")
634 .header("x-custom", "hello")
635 .body(body)
636 .expect("hyper request builds");
637
638 let (method, url, headers, _body) =
639 to_request_parts(hyper_req).expect("conversion succeeds");
640
641 assert_eq!(method, Method::GET);
642 assert_eq!(url, "https://example.com/foo?bar=baz");
643 assert_eq!(
644 headers.get("x-custom").and_then(|v| v.to_str().ok()),
645 Some("hello")
646 );
647 }
648
649 #[test]
650 fn converts_post_request_with_body_and_port() {
651 let body_bytes = bytes::Bytes::from_static(b"payload");
652 let body: WasiBody = http_body_util::Full::new(body_bytes)
653 .map_err(|_| unreachable!())
654 .boxed_unsync();
655 let hyper_req = hyper::Request::builder()
656 .method(Method::POST)
657 .uri("http://api.example.com:8080/v1/create")
658 .header("content-type", "application/json")
659 .body(body)
660 .expect("hyper request builds");
661
662 let (method, url, headers, _body) =
663 to_request_parts(hyper_req).expect("conversion succeeds");
664
665 assert_eq!(method, Method::POST);
666 assert_eq!(url, "http://api.example.com:8080/v1/create");
667 assert_eq!(
668 headers.get("content-type").and_then(|v| v.to_str().ok()),
669 Some("application/json")
670 );
671 }
672
673 #[tokio::test(flavor = "current_thread")]
674 async fn send_fetches_example_dot_com() {
675 let body: WasiBody = Empty::<bytes::Bytes>::new()
677 .map_err(|_| unreachable!())
678 .boxed_unsync();
679 let hyper_req = hyper::Request::builder()
680 .method(Method::GET)
681 .uri("https://example.com/")
682 .body(body)
683 .unwrap();
684
685 let cfg = HttpConfig {
686 mode: act_policy::grant::PolicyMode::Open,
687 ..Default::default()
688 };
689 let client = ActHttpClient::new(cfg).expect("client builds");
690 let options = RequestOptions {
691 connect_timeout: Some(std::time::Duration::from_secs(10)),
692 first_byte_timeout: Some(std::time::Duration::from_secs(10)),
693 between_bytes_timeout: Some(std::time::Duration::from_secs(10)),
694 };
695 let (incoming, _io) = client
696 .send(hyper_req, Some(options))
697 .await
698 .expect("send succeeds");
699 assert_eq!(
700 incoming.status().as_u16(),
701 200,
702 "example.com should return 200"
703 );
704 }
705
706 #[test]
714 fn maps_each_error_kind_to_its_wasi_error() {
715 use hclient::ErrorKind;
716 let io = || std::io::Error::other("under test");
717
718 for (kind, expected) in [
719 (ErrorKind::Connect, HttpError::ConnectionRefused),
720 (ErrorKind::Redirect, HttpError::HttpRequestDenied),
721 ] {
722 let named = format!("{kind:?}");
723 let mapped = client_error_to_wasi(hclient::Error::new(kind, io()));
724 assert_eq!(
725 std::mem::discriminant(&mapped),
726 std::mem::discriminant(&expected),
727 "{named} mapped to {mapped:?}"
728 );
729 }
730
731 let mapped = client_error_to_wasi(hclient::Error::new(ErrorKind::Resolve, io()));
734 assert!(
735 matches!(mapped, HttpError::DnsError { rcode: Some(_), .. }),
736 "a resolve failure must reach the guest as a DNS error naming it, got {mapped:?}"
737 );
738
739 assert!(matches!(
742 client_error_to_wasi(hclient::Error::new(ErrorKind::Redirect, io())),
743 HttpError::HttpRequestDenied
744 ));
745 }
746
747 #[tokio::test(flavor = "current_thread")]
748 async fn redirect_policy_blocks_cross_host_hop() {
749 use act_policy::Decision;
750 use act_policy::grant::PolicyMode;
751 use act_policy::net::{NetworkCheck, NetworkRule, decide};
752
753 let allow = vec![NetworkRule {
754 host: Some("primary.example".into()),
755 ..Default::default()
756 }];
757 let deny: Vec<NetworkRule> = vec![];
758
759 let blocked = decide(
760 PolicyMode::Allowlist,
761 &allow,
762 &deny,
763 &NetworkCheck::new("other.example", 443),
764 );
765 assert_eq!(blocked, Decision::Deny);
766
767 let allowed = decide(
768 PolicyMode::Allowlist,
769 &allow,
770 &deny,
771 &NetworkCheck::new("primary.example", 443),
772 );
773 assert_eq!(allowed, Decision::Allow);
774 }
775
776 #[tokio::test(flavor = "current_thread")]
777 async fn dns_resolver_filters_denied_cidr() {
778 use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
779 use act_policy::net::NetworkRule;
780
781 let cfg = HttpConfig {
782 mode: PolicyMode::Allowlist,
783 allow: vec![HttpRule {
784 net: NetworkRule {
785 host: Some("localhost".into()),
786 ..Default::default()
787 },
788 ..Default::default()
789 }],
790 deny: vec![HttpRule {
792 net: NetworkRule {
793 cidr: Some("127.0.0.0/8".into()),
794 ..Default::default()
795 },
796 ..Default::default()
797 }],
798 };
799 let client = ActHttpClient::new(cfg).expect("client builds");
800 let body: WasiBody = Empty::<bytes::Bytes>::new()
801 .map_err(|_| unreachable!())
802 .boxed_unsync();
803 let hyper_req = hyper::Request::builder()
804 .method(Method::GET)
805 .uri("http://localhost/")
806 .body(body)
807 .unwrap();
808 let options = RequestOptions {
809 connect_timeout: Some(std::time::Duration::from_secs(5)),
810 first_byte_timeout: Some(std::time::Duration::from_secs(5)),
811 between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
812 };
813 let err = match client.send(hyper_req, Some(options)).await {
814 Ok(_) => panic!("localhost resolves into denied 127/8, should fail"),
815 Err(e) => e,
816 };
817 assert!(
821 matches!(err, HttpError::DnsError { .. })
822 || matches!(err, HttpError::ConnectionRefused),
823 "expected DnsError or ConnectionRefused, got {err:?}"
824 );
825 }
826
827 #[test]
840 fn svcb_address_hints_are_filtered_like_any_other_address() {
841 use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
842 use act_policy::net::NetworkRule;
843
844 let cfg = HttpConfig {
845 mode: PolicyMode::Allowlist,
846 allow: vec![HttpRule {
847 net: NetworkRule {
848 cidr: Some("10.0.0.0/8".into()),
849 ..Default::default()
850 },
851 ..Default::default()
852 }],
853 deny: vec![],
854 };
855 let r = PolicyDnsResolver::new(&cfg);
856
857 let inside: std::net::Ipv4Addr = "10.1.2.3".parse().unwrap();
858 let outside: std::net::Ipv4Addr = "93.184.216.34".parse().unwrap();
859
860 assert!(r.keeps_record(
863 "example.com",
864 &hclient_dns::Record::new(hclient_dns::RData::A(inside))
865 ));
866 assert!(!r.keeps_record(
867 "example.com",
868 &hclient_dns::Record::new(hclient_dns::RData::A(outside))
869 ));
870
871 let mut ep = hclient_dns::SvcbEndpoint::new(1, "example.com".into());
872 ep.ipv4hint = vec![outside];
873 assert!(
874 !r.keeps_record(
875 "example.com",
876 &hclient_dns::Record::new(hclient_dns::RData::Https(ep))
877 ),
878 "an HTTPS record's ipv4hint is an address the connector can dial, \
879 so it must meet the same rule an A record does"
880 );
881
882 let mut ep_ok = hclient_dns::SvcbEndpoint::new(1, "example.com".into());
883 ep_ok.ipv4hint = vec![inside];
884 assert!(
885 r.keeps_record(
886 "example.com",
887 &hclient_dns::Record::new(hclient_dns::RData::Https(ep_ok))
888 ),
889 "a hint inside the allowed CIDR must pass — this is a filter, not \
890 a blanket refusal of HTTPS records"
891 );
892 }
893
894 #[tokio::test(flavor = "current_thread")]
895 #[ignore = "network: resolves example.com through the system resolver"]
903 async fn dns_resolver_requires_allow_cidr_match_for_hostnames() {
904 use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
907 use act_policy::net::NetworkRule;
908
909 let cfg = HttpConfig {
910 mode: PolicyMode::Allowlist,
911 allow: vec![HttpRule {
913 net: NetworkRule {
914 cidr: Some("10.0.0.0/8".into()),
915 ..Default::default()
916 },
917 ..Default::default()
918 }],
919 deny: vec![],
920 };
921 let client = ActHttpClient::new(cfg).expect("client builds");
922 let body: WasiBody = Empty::<bytes::Bytes>::new()
923 .map_err(|_| unreachable!())
924 .boxed_unsync();
925 let hyper_req = hyper::Request::builder()
926 .method(Method::GET)
927 .uri("https://example.com/")
928 .body(body)
929 .unwrap();
930 let options = RequestOptions {
931 connect_timeout: Some(std::time::Duration::from_secs(5)),
932 first_byte_timeout: Some(std::time::Duration::from_secs(5)),
933 between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
934 };
935 let err = match client.send(hyper_req, Some(options)).await {
936 Ok(_) => panic!("example.com IPs not in 10/8, must fail at DNS"),
937 Err(e) => e,
938 };
939 assert!(
940 matches!(err, HttpError::DnsError { .. }),
941 "expected DnsError, got {err:?}"
942 );
943 assert!(
951 client.resolver.filtered_everything("example.com"),
952 "the DnsError must come from policy refusing every address, not \
953 from a resolver that never answered — this test needs DNS"
954 );
955 }
956
957 #[tokio::test(flavor = "current_thread")]
958 #[ignore = "network: makes a real HTTPS request to example.com"]
959 async fn dns_resolver_host_match_bypasses_allow_cidr() {
960 use act_policy::grant::{HttpConfig, HttpRule, PolicyMode};
964 use act_policy::net::NetworkRule;
965
966 let cfg = HttpConfig {
967 mode: PolicyMode::Allowlist,
968 allow: vec![
969 HttpRule {
970 net: NetworkRule {
971 host: Some("example.com".into()),
972 ..Default::default()
973 },
974 ..Default::default()
975 },
976 HttpRule {
977 net: NetworkRule {
978 cidr: Some("10.0.0.0/8".into()),
979 ..Default::default()
980 },
981 ..Default::default()
982 },
983 ],
984 deny: vec![],
985 };
986 let client = ActHttpClient::new(cfg).expect("client builds");
987 let body: WasiBody = Empty::<bytes::Bytes>::new()
988 .map_err(|_| unreachable!())
989 .boxed_unsync();
990 let hyper_req = hyper::Request::builder()
991 .method(Method::GET)
992 .uri("https://example.com/")
993 .body(body)
994 .unwrap();
995 let options = RequestOptions {
996 connect_timeout: Some(std::time::Duration::from_secs(10)),
997 first_byte_timeout: Some(std::time::Duration::from_secs(10)),
998 between_bytes_timeout: Some(std::time::Duration::from_secs(10)),
999 };
1000 let (incoming, _io) = client
1001 .send(hyper_req, Some(options))
1002 .await
1003 .expect("example.com allowed via host rule");
1004 assert_eq!(incoming.status().as_u16(), 200);
1005 }
1006
1007 #[derive(Clone, Default)]
1012 struct CapturingWriter(Arc<Mutex<Vec<String>>>);
1013 impl crate::audit::layer::AuditWriter for CapturingWriter {
1014 fn write_line(&self, line: &str) {
1015 self.0.lock().unwrap().push(line.to_string());
1016 }
1017 }
1018
1019 #[tokio::test(flavor = "current_thread")]
1041 async fn redirect_hop_denial_is_audited() {
1042 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1043 use tracing_subscriber::prelude::*;
1044
1045 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1046 .await
1047 .expect("bind loopback");
1048 let addr = listener.local_addr().unwrap();
1049 let server = tokio::spawn(async move {
1050 let (mut stream, _) = listener.accept().await.expect("accept");
1051 let mut buf = [0u8; 1024];
1052 let _ = stream.read(&mut buf).await; let resp = b"HTTP/1.1 302 Found\r\n\
1054 Location: http://blocked.example/\r\n\
1055 Content-Length: 0\r\n\
1056 Connection: close\r\n\r\n";
1057 let _ = stream.write_all(resp).await;
1058 let _ = stream.shutdown().await;
1059 });
1060
1061 let cfg = HttpConfig {
1065 mode: PolicyMode::Allowlist,
1066 allow: vec![act_policy::grant::HttpRule {
1067 net: NetworkRule {
1068 host: Some("127.0.0.1".into()),
1069 ..Default::default()
1070 },
1071 ..Default::default()
1072 }],
1073 deny: vec![],
1074 };
1075 act_store::fetch::install_crypto_provider();
1076 let resolver = PolicyDnsResolver::new(&cfg);
1077 let transport = hclient_native::Native::new(
1078 hclient_rt_tokio::Tokio,
1079 hclient_tls_rustls::Rustls::with_webpki_roots(),
1080 resolver.clone(),
1081 );
1082 let client = hclient::Client::builder(transport)
1083 .redirect(CeilingRedirectPolicy(cfg))
1084 .build()
1085 .expect("client builds");
1086
1087 let writer = CapturingWriter::default();
1088 let sink = writer.0.clone();
1089 let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
1090 writer,
1091 crate::audit::Detail::Rollup,
1092 ));
1093 let _guard = tracing::subscriber::set_default(sub);
1094
1095 let result = client.get(format!("http://{addr}/")).send().await;
1096
1097 drop(_guard);
1098 server.await.expect("server task");
1099
1100 let err = result.expect_err("redirect target denied, the request must fail");
1101 assert!(
1102 matches!(err.kind(), hclient::ErrorKind::Redirect),
1103 "expected a redirect-class error, got {err:?}"
1104 );
1105
1106 let lines = sink.lock().unwrap().clone();
1107 let deny_line = lines
1108 .iter()
1109 .find(|l| l.contains("blocked.example"))
1110 .unwrap_or_else(|| panic!("no redirect-deny audit line, got {lines:?}"));
1111 assert!(deny_line.contains("wasi:http"), "got {deny_line}");
1112 assert!(
1113 deny_line.contains("redirect target outside ceiling"),
1114 "reason must distinguish this from an ordinary ceiling denial, got {deny_line}"
1115 );
1116 }
1117
1118 #[tokio::test(flavor = "current_thread")]
1131 async fn dns_cidr_filtered_resolution_is_audited() {
1132 use act_policy::grant::{HttpConfig as PolicyHttpConfig, HttpRule};
1133 use act_policy::net::NetworkRule as PolicyNetworkRule;
1134 use tracing_subscriber::prelude::*;
1135
1136 let cfg = PolicyHttpConfig {
1137 mode: PolicyMode::Allowlist,
1138 allow: vec![HttpRule {
1139 net: PolicyNetworkRule {
1140 host: Some("localhost".into()),
1141 ..Default::default()
1142 },
1143 ..Default::default()
1144 }],
1145 deny: vec![
1146 HttpRule {
1147 net: PolicyNetworkRule {
1148 cidr: Some("127.0.0.0/8".into()),
1149 ..Default::default()
1150 },
1151 ..Default::default()
1152 },
1153 HttpRule {
1154 net: PolicyNetworkRule {
1155 cidr: Some("::1/128".into()),
1156 ..Default::default()
1157 },
1158 ..Default::default()
1159 },
1160 ],
1161 };
1162 let client = ActHttpClient::new(cfg).expect("client builds");
1163 let body: WasiBody = Empty::<bytes::Bytes>::new()
1164 .map_err(|_| unreachable!())
1165 .boxed_unsync();
1166 let hyper_req = hyper::Request::builder()
1167 .method(Method::GET)
1168 .uri("http://localhost/")
1169 .body(body)
1170 .unwrap();
1171 let options = RequestOptions {
1172 connect_timeout: Some(std::time::Duration::from_secs(5)),
1173 first_byte_timeout: Some(std::time::Duration::from_secs(5)),
1174 between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
1175 };
1176
1177 let writer = CapturingWriter::default();
1178 let sink = writer.0.clone();
1179 let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
1180 writer,
1181 crate::audit::Detail::Rollup,
1182 ));
1183 let _guard = tracing::subscriber::set_default(sub);
1184
1185 let err = match client.send(hyper_req, Some(options)).await {
1186 Ok(_) => panic!("both loopback families are denied, must fail at DNS"),
1187 Err(e) => e,
1188 };
1189
1190 drop(_guard);
1191
1192 assert!(
1193 matches!(err, HttpError::DnsError { .. }),
1194 "expected DnsError, got {err:?}"
1195 );
1196
1197 let lines = sink.lock().unwrap().clone();
1198 let deny_line = lines
1199 .iter()
1200 .find(|l| l.contains("localhost"))
1201 .unwrap_or_else(|| panic!("no dns-filtered deny audit line, got {lines:?}"));
1202 assert!(deny_line.contains("wasi:http"), "got {deny_line}");
1203 assert!(
1204 deny_line.contains("all resolved addresses filtered by CIDR rule"),
1205 "reason must distinguish this from an ordinary ceiling denial, got {deny_line}"
1206 );
1207 }
1208}