Skip to content

Commit 7ce91c5

Browse files
committed
fix(http): enforce address and response deadline policy
1 parent f1613b5 commit 7ce91c5

1 file changed

Lines changed: 138 additions & 18 deletions

File tree

src/builtins/runtime/http.rs

Lines changed: 138 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use crate::builtins::runtime::resource::ResourceTypeId;
2020
use crate::vm::Value;
2121
use crate::vm::{CallReturn, HostOpId, VmError};
2222

23-
#[derive(Clone, Debug)]
23+
#[derive(Clone, Debug, PartialEq, Eq)]
2424
pub 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(
534539
fn 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\nContent-Length: 4\r\nConnection: 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

Comments
 (0)