@@ -20,7 +20,7 @@ use crate::builtins::runtime::resource::ResourceTypeId;
2020use crate :: vm:: Value ;
2121use crate :: vm:: { CallReturn , HostOpId , VmError } ;
2222
23- #[ derive( Clone , Debug ) ]
23+ #[ derive( Clone , Debug , PartialEq , Eq ) ]
2424pub struct HttpConfig {
2525 pub allowed_schemes : Vec < String > ,
2626 pub allowed_hosts : Vec < String > ,
@@ -94,6 +94,11 @@ impl HttpState {
9494 }
9595 }
9696
97+ #[ cfg( all( test, feature = "http-client" ) ) ]
98+ pub ( crate ) fn configuration ( & self ) -> Option < & HttpConfig > {
99+ self . config . as_ref ( )
100+ }
101+
97102 pub ( crate ) fn is_configured ( & self ) -> bool {
98103 #[ cfg( feature = "http-client" ) ]
99104 {
@@ -534,23 +539,38 @@ fn validate_resolved_addresses(
534539fn is_restricted_ip ( ip : std:: net:: IpAddr ) -> bool {
535540 match ip {
536541 std:: net:: IpAddr :: V4 ( ip) => {
537- ip. is_loopback ( )
538- || ip. is_private ( )
539- || ip. is_link_local ( )
540- || ip. is_broadcast ( )
541- || ip. is_documentation ( )
542- || ip. is_multicast ( )
543- || ip. is_unspecified ( )
542+ let octets = ip. octets ( ) ;
543+ matches ! ( octets[ 0 ] , 0 | 10 | 127 )
544+ || ( octets[ 0 ] == 100 && ( 64 ..=127 ) . contains ( & octets[ 1 ] ) )
545+ || ( octets[ 0 ] == 169 && octets[ 1 ] == 254 )
546+ || ( octets[ 0 ] == 172 && ( 16 ..=31 ) . contains ( & octets[ 1 ] ) )
547+ || ( octets[ 0 ] == 192
548+ && matches ! (
549+ ( octets[ 1 ] , octets[ 2 ] ) ,
550+ ( 0 , 0 ) | ( 0 , 2 ) | ( 31 , 196 ) | ( 52 , 193 ) | ( 88 , 99 ) | ( 168 , _) | ( 175 , 48 )
551+ ) )
552+ || ( octets[ 0 ] == 198
553+ && ( ( 18 ..=19 ) . contains ( & octets[ 1 ] ) || ( octets[ 1 ] == 51 && octets[ 2 ] == 100 ) ) )
554+ || ( octets[ 0 ] == 203 && octets[ 1 ] == 0 && octets[ 2 ] == 113 )
555+ || octets[ 0 ] >= 224
544556 }
545557 std:: net:: IpAddr :: V6 ( ip) => {
546558 if let Some ( mapped) = ip. to_ipv4_mapped ( ) {
547559 return is_restricted_ip ( std:: net:: IpAddr :: V4 ( mapped) ) ;
548560 }
549- ip. is_loopback ( )
550- || ip. is_unique_local ( )
551- || ip. is_unicast_link_local ( )
552- || ip. is_unspecified ( )
553- || ip. is_multicast ( )
561+ let segments = ip. segments ( ) ;
562+ let outside_global_unicast = segments[ 0 ] & 0xe000 != 0x2000 ;
563+ let protocol_assignments = segments[ 0 ] == 0x2001 && segments[ 1 ] <= 0x01ff ;
564+ let documentation = ( segments[ 0 ] == 0x2001 && segments[ 1 ] == 0x0db8 )
565+ || ( segments[ 0 ] == 0x3fff && segments[ 1 ] & 0xf000 == 0 ) ;
566+ let six_to_four = segments[ 0 ] == 0x2002 ;
567+ let direct_delegation_as112 =
568+ segments[ 0 ] == 0x2620 && segments[ 1 ] == 0x004f && segments[ 2 ] == 0x8000 ;
569+ outside_global_unicast
570+ || protocol_assignments
571+ || documentation
572+ || six_to_four
573+ || direct_delegation_as112
554574 }
555575 }
556576}
@@ -663,13 +683,15 @@ async fn execute_request(
663683 while let Some ( chunk) = {
664684 let remaining = deadline. saturating_duration_since ( std:: time:: Instant :: now ( ) ) ;
665685 if remaining. is_zero ( ) {
666- return Err ( VmError :: HostError (
667- "HTTP response read timed out" . to_string ( ) ,
668- ) ) ;
686+ token. cancel ( CancellationReason :: Deadline ) ;
687+ return Err ( cancellation_vm_error ( token) ) ;
669688 }
670689 tokio:: time:: timeout ( remaining, stream. next ( ) )
671690 . await
672- . map_err ( |_| VmError :: HostError ( "HTTP response read timed out" . to_string ( ) ) ) ?
691+ . map_err ( |_| {
692+ token. cancel ( CancellationReason :: Deadline ) ;
693+ cancellation_vm_error ( token)
694+ } ) ?
673695 } {
674696 token. check ( ) . map_err ( runtime_host_error) ?;
675697 let chunk = chunk. map_err ( |error| {
@@ -710,7 +732,8 @@ mod tests {
710732 #[ cfg( feature = "http-client" ) ]
711733 use super :: {
712734 CancellationReason , HttpRequest , HttpRequestResource , OperationOwner , ResourceTypeId ,
713- execute_request, is_restricted_ip, schedule_request, validate_url,
735+ execute_request, is_restricted_ip, schedule_request, validate_resolved_addresses,
736+ validate_url,
714737 } ;
715738 #[ cfg( feature = "http-client" ) ]
716739 use crate :: builtins:: runtime:: cancellation:: OperationId ;
@@ -825,6 +848,59 @@ mod tests {
825848 server. join ( ) . expect ( "server should exit" ) ;
826849 }
827850
851+ #[ cfg( feature = "http-client" ) ]
852+ #[ test]
853+ fn response_body_timeout_sets_structured_deadline_reason ( ) {
854+ use std:: io:: { Read , Write } ;
855+ use std:: time:: { Duration , Instant } ;
856+
857+ let listener = std:: net:: TcpListener :: bind ( "127.0.0.1:0" ) . expect ( "listener should bind" ) ;
858+ let address = listener. local_addr ( ) . expect ( "listener should have address" ) ;
859+ let server = std:: thread:: spawn ( move || {
860+ let ( mut socket, _) = listener. accept ( ) . expect ( "request should connect" ) ;
861+ let mut request = [ 0u8 ; 1024 ] ;
862+ let _ = socket
863+ . read ( & mut request)
864+ . expect ( "request should be readable" ) ;
865+ socket
866+ . write_all ( b"HTTP/1.1 200 OK\r \n Content-Length: 4\r \n Connection: close\r \n \r \n " )
867+ . expect ( "headers should be written" ) ;
868+ socket. flush ( ) . expect ( "headers should flush" ) ;
869+ std:: thread:: sleep ( Duration :: from_millis ( 100 ) ) ;
870+ } ) ;
871+ let config = HttpConfig {
872+ allowed_schemes : vec ! [ "http" . to_string( ) ] ,
873+ allowed_hosts : vec ! [ "127.0.0.1" . to_string( ) ] ,
874+ allowed_ports : vec ! [ address. port( ) ] ,
875+ allow_private_ips : true ,
876+ connect_timeout : Duration :: from_millis ( 50 ) ,
877+ request_timeout : Duration :: from_millis ( 20 ) ,
878+ ..HttpConfig :: default ( )
879+ } ;
880+ let request = HttpRequest {
881+ method : reqwest:: Method :: GET ,
882+ url : format ! ( "http://{address}/" ) . parse ( ) . expect ( "valid URL" ) ,
883+ headers : Vec :: new ( ) ,
884+ body : None ,
885+ } ;
886+ let token = crate :: builtins:: runtime:: cancellation:: CancellationToken :: root ( ) ;
887+ let runtime = tokio:: runtime:: Builder :: new_current_thread ( )
888+ . enable_all ( )
889+ . build ( )
890+ . expect ( "runtime should build" ) ;
891+
892+ runtime
893+ . block_on ( execute_request (
894+ & config,
895+ & request,
896+ & token,
897+ Instant :: now ( ) + config. request_timeout ,
898+ ) )
899+ . expect_err ( "stalled response body should time out" ) ;
900+ assert_eq ! ( token. reason( ) , Some ( CancellationReason :: Deadline ) ) ;
901+ server. join ( ) . expect ( "server should exit" ) ;
902+ }
903+
828904 #[ cfg( feature = "http-client" ) ]
829905 #[ test]
830906 fn empty_port_allowlist_rejects_explicit_and_default_ports ( ) {
@@ -839,6 +915,50 @@ mod tests {
839915 assert ! ( validate_url( & config, & default_port) . is_err( ) ) ;
840916 }
841917
918+ #[ cfg( feature = "http-client" ) ]
919+ #[ test]
920+ fn special_use_networks_and_mixed_dns_answers_are_restricted ( ) {
921+ for address in [
922+ "0.1.2.3" ,
923+ "100.64.0.1" ,
924+ "192.0.0.8" ,
925+ "192.0.2.1" ,
926+ "192.31.196.1" ,
927+ "192.52.193.1" ,
928+ "192.88.99.1" ,
929+ "192.175.48.1" ,
930+ "198.18.0.1" ,
931+ "198.51.100.1" ,
932+ "203.0.113.1" ,
933+ "240.0.0.1" ,
934+ "100::1" ,
935+ "2001::1" ,
936+ "2001:db8::1" ,
937+ "2002::1" ,
938+ "2620:4f:8000::1" ,
939+ "3fff::1" ,
940+ "fc00::1" ,
941+ ] {
942+ assert ! (
943+ is_restricted_ip( address. parse( ) . expect( "valid IP" ) ) ,
944+ "{address} must be restricted"
945+ ) ;
946+ }
947+ for address in [ "8.8.8.8" , "1.1.1.1" , "2606:4700:4700::1111" ] {
948+ assert ! (
949+ !is_restricted_ip( address. parse( ) . expect( "valid IP" ) ) ,
950+ "{address} must remain globally routable"
951+ ) ;
952+ }
953+
954+ let config = HttpConfig :: default ( ) ;
955+ let addresses = [
956+ "8.8.8.8:443" . parse ( ) . expect ( "valid socket address" ) ,
957+ "100.64.0.1:443" . parse ( ) . expect ( "valid socket address" ) ,
958+ ] ;
959+ assert ! ( validate_resolved_addresses( & config, & addresses) . is_err( ) ) ;
960+ }
961+
842962 #[ cfg( feature = "http-client" ) ]
843963 #[ test]
844964 fn ipv4_mapped_ipv6_loopback_is_restricted ( ) {
0 commit comments