@@ -19,50 +19,95 @@ use wasmtime_wasi_http::p2::bindings::http::types::ErrorCode as P2ErrorCode;
1919use wasmtime_wasi_http:: p2:: body:: HyperIncomingBody ;
2020use wasmtime_wasi_http:: p3:: bindings:: http:: types:: ErrorCode as P3ErrorCode ;
2121
22- use crate :: config:: HttpConfig ;
22+ use crate :: config:: { HttpConfig , PolicyMode } ;
2323use crate :: runtime:: network:: { self , NetworkRule } ;
2424
25- /// reqwest DNS resolver that filters resolved addresses against deny-CIDR
26- /// rules. Drops every resolved `SocketAddr` whose IP matches any deny CIDR
27- /// (respecting `except_ports`). If no addresses survive, returns an empty
28- /// iterator — reqwest surfaces this as a DNS error, which our
29- /// `reqwest_to_p2_error` / `reqwest_to_p3_error` maps to the appropriate
30- /// wasi:http error variant.
25+ /// reqwest DNS resolver that filters resolved addresses against both deny
26+ /// and allow CIDR rules.
27+ ///
28+ /// Logic per resolved `SocketAddr`:
29+ /// 1. Drop if any deny-CIDR matches (respecting `except_ports`).
30+ /// 2. In `Allowlist` mode, if any allow rule carries a `cidr`, the IP must
31+ /// be covered by either a host-anchored allow (meaning the hostname
32+ /// itself was allowed, so every resolved IP is OK) or an allow-CIDR.
33+ /// This closes the prior asymmetry where `allow = [{ cidr = "..." }]`
34+ /// required an IP-literal URI.
35+ /// 3. `Open` / `Deny` modes: no allow-side filter here (`Deny` never
36+ /// reaches the resolver; `Open` still honors deny-CIDR as a safety
37+ /// net).
38+ ///
39+ /// If no addresses survive, returns an empty iterator — reqwest surfaces
40+ /// this as a DNS error, which our `reqwest_to_p2_error` /
41+ /// `reqwest_to_p3_error` maps to `ErrorCode::DnsError`.
3142struct PolicyDnsResolver {
43+ allow_nets : Arc < Vec < NetworkRule > > ,
3244 deny_nets : Arc < Vec < NetworkRule > > ,
45+ mode : PolicyMode ,
3346}
3447
3548impl PolicyDnsResolver {
3649 fn new ( cfg : & HttpConfig ) -> Self {
50+ let allow_nets = cfg. allow . iter ( ) . map ( |r| r. net . clone ( ) ) . collect ( ) ;
3751 let deny_nets = cfg. deny . iter ( ) . map ( |r| r. net . clone ( ) ) . collect ( ) ;
3852 Self {
53+ allow_nets : Arc :: new ( allow_nets) ,
3954 deny_nets : Arc :: new ( deny_nets) ,
55+ mode : cfg. mode ,
4056 }
4157 }
4258}
4359
4460impl Resolve for PolicyDnsResolver {
4561 fn resolve ( & self , name : Name ) -> Resolving {
62+ let allow = self . allow_nets . clone ( ) ;
4663 let deny = self . deny_nets . clone ( ) ;
64+ let mode = self . mode ;
4765 Box :: pin ( async move {
4866 let host = name. as_str ( ) . to_string ( ) ;
4967 let addrs = tokio:: net:: lookup_host ( format ! ( "{host}:0" ) )
5068 . await
5169 . map_err ( |e| Box :: new ( e) as Box < dyn std:: error:: Error + Send + Sync > ) ?;
5270 let all: Vec < SocketAddr > = addrs. collect ( ) ;
5371 let total = all. len ( ) ;
72+
73+ // If the hostname itself matches any host-anchored allow rule,
74+ // we don't need to require per-IP CIDR matches — the guest is
75+ // already allowed to talk to this host. Compute once.
76+ let host_allowed = allow. iter ( ) . any ( |r| {
77+ r. host
78+ . as_deref ( )
79+ . is_some_and ( |pat| network:: host_matches ( pat, & host) )
80+ } ) ;
81+ let require_allow_cidr = mode == PolicyMode :: Allowlist
82+ && !host_allowed
83+ && allow. iter ( ) . any ( |r| r. cidr . is_some ( ) ) ;
84+
5485 let filtered: Vec < SocketAddr > = all
5586 . into_iter ( )
56- . filter ( |addr| !network:: any_deny_cidr_matches ( & deny, addr. ip ( ) , addr. port ( ) ) )
87+ . filter ( |addr| {
88+ if network:: any_deny_cidr_matches ( & deny, addr. ip ( ) , addr. port ( ) ) {
89+ return false ;
90+ }
91+ if require_allow_cidr {
92+ return allow. iter ( ) . any ( |r| {
93+ r. cidr
94+ . as_deref ( )
95+ . is_some_and ( |c| network:: cidr_contains ( c, addr. ip ( ) ) )
96+ } ) ;
97+ }
98+ true
99+ } )
57100 . collect ( ) ;
58101 tracing:: debug!(
59102 %host,
60103 resolved = total,
61104 kept = filtered. len( ) ,
105+ require_allow_cidr,
106+ host_allowed,
62107 "http policy dns resolve" ,
63108 ) ;
64109 if filtered. is_empty ( ) {
65- return Err ( "all resolved addresses matched a deny CIDR" . into ( ) ) ;
110+ return Err ( "all resolved addresses filtered by policy CIDR rules " . into ( ) ) ;
66111 }
67112 let iter: Addrs = Box :: new ( filtered. into_iter ( ) ) ;
68113 Ok ( iter)
@@ -712,4 +757,100 @@ mod tests {
712757 "expected DnsError or ConnectionRefused, got {err:?}"
713758 ) ;
714759 }
760+
761+ #[ tokio:: test( flavor = "current_thread" ) ]
762+ async fn dns_resolver_requires_allow_cidr_match_for_hostnames ( ) {
763+ // mode=Allowlist with only an allow-CIDR rule. Any URI whose
764+ // resolved IPs land outside that CIDR must fail at DNS level.
765+ use crate :: config:: { HttpConfig , HttpRule , PolicyMode } ;
766+ use crate :: runtime:: network:: NetworkRule ;
767+
768+ let cfg = HttpConfig {
769+ mode : PolicyMode :: Allowlist ,
770+ // Only permit internal RFC1918 space — example.com is public.
771+ allow : vec ! [ HttpRule {
772+ net: NetworkRule {
773+ cidr: Some ( "10.0.0.0/8" . into( ) ) ,
774+ ..Default :: default ( )
775+ } ,
776+ ..Default :: default ( )
777+ } ] ,
778+ deny : vec ! [ ] ,
779+ ..Default :: default ( )
780+ } ;
781+ let client = ActHttpClient :: new ( cfg) . expect ( "client builds" ) ;
782+ let body: UnsyncBoxBody < bytes:: Bytes , P2ErrorCode > = Empty :: < bytes:: Bytes > :: new ( )
783+ . map_err ( |_| unreachable ! ( ) )
784+ . boxed_unsync ( ) ;
785+ let hyper_req = hyper:: Request :: builder ( )
786+ . method ( Method :: GET )
787+ . uri ( "https://example.com/" )
788+ . body ( body)
789+ . unwrap ( ) ;
790+ let config = wasmtime_wasi_http:: p2:: types:: OutgoingRequestConfig {
791+ use_tls : true ,
792+ connect_timeout : std:: time:: Duration :: from_secs ( 5 ) ,
793+ first_byte_timeout : std:: time:: Duration :: from_secs ( 5 ) ,
794+ between_bytes_timeout : std:: time:: Duration :: from_secs ( 5 ) ,
795+ } ;
796+ let err = client
797+ . send_p2 ( hyper_req, config)
798+ . await
799+ . expect_err ( "example.com IPs not in 10/8, must fail at DNS" ) ;
800+ assert ! (
801+ matches!( err, P2ErrorCode :: DnsError ( _) ) ,
802+ "expected DnsError, got {err:?}"
803+ ) ;
804+ }
805+
806+ #[ tokio:: test( flavor = "current_thread" ) ]
807+ async fn dns_resolver_host_match_bypasses_allow_cidr ( ) {
808+ // mode=Allowlist with BOTH a host-allow AND an allow-CIDR. A
809+ // request to the allowed host should succeed even if its IPs
810+ // don't fall in the CIDR — the host match approves all IPs.
811+ use crate :: config:: { HttpConfig , HttpRule , PolicyMode } ;
812+ use crate :: runtime:: network:: NetworkRule ;
813+
814+ let cfg = HttpConfig {
815+ mode : PolicyMode :: Allowlist ,
816+ allow : vec ! [
817+ HttpRule {
818+ net: NetworkRule {
819+ host: Some ( "example.com" . into( ) ) ,
820+ ..Default :: default ( )
821+ } ,
822+ ..Default :: default ( )
823+ } ,
824+ HttpRule {
825+ net: NetworkRule {
826+ cidr: Some ( "10.0.0.0/8" . into( ) ) ,
827+ ..Default :: default ( )
828+ } ,
829+ ..Default :: default ( )
830+ } ,
831+ ] ,
832+ deny : vec ! [ ] ,
833+ ..Default :: default ( )
834+ } ;
835+ let client = ActHttpClient :: new ( cfg) . expect ( "client builds" ) ;
836+ let body: UnsyncBoxBody < bytes:: Bytes , P2ErrorCode > = Empty :: < bytes:: Bytes > :: new ( )
837+ . map_err ( |_| unreachable ! ( ) )
838+ . boxed_unsync ( ) ;
839+ let hyper_req = hyper:: Request :: builder ( )
840+ . method ( Method :: GET )
841+ . uri ( "https://example.com/" )
842+ . body ( body)
843+ . unwrap ( ) ;
844+ let config = wasmtime_wasi_http:: p2:: types:: OutgoingRequestConfig {
845+ use_tls : true ,
846+ connect_timeout : std:: time:: Duration :: from_secs ( 10 ) ,
847+ first_byte_timeout : std:: time:: Duration :: from_secs ( 10 ) ,
848+ between_bytes_timeout : std:: time:: Duration :: from_secs ( 10 ) ,
849+ } ;
850+ let incoming = client
851+ . send_p2 ( hyper_req, config)
852+ . await
853+ . expect ( "example.com allowed via host rule" ) ;
854+ assert_eq ! ( incoming. resp. status( ) . as_u16( ) , 200 ) ;
855+ }
715856}
0 commit comments