From 21108913fae99975e761727e252f2d043b9e7329 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 9 Feb 2026 16:37:23 -0800 Subject: [PATCH 1/9] Block procedures from requesting private ip ranges --- crates/core/src/host/instance_env.rs | 176 ++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 1 deletion(-) diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 7c014c4d937..2a8bf115ed4 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -31,6 +31,7 @@ use spacetimedb_table::indexes::RowPointer; use spacetimedb_table::table::RowRef; use std::fmt::Display; use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::ops::DerefMut; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -871,7 +872,11 @@ impl InstanceEnv { // Actually execute the HTTP request! // TODO(perf): Stash a long-lived `Client` in the env somewhere, rather than building a new one for each call. - let execute_fut = reqwest::Client::new().execute(reqwest); + let execute_fut = reqwest::Client::builder() + .dns_resolver(Arc::new(FilteredDnsResolver)) + .build() + .map_err(http_error)? + .execute(reqwest); // Run the future that does IO work on a tokio worker thread, where it's more efficent. let response_fut = tokio::spawn(async { @@ -938,6 +943,135 @@ impl InstanceEnv { /// Value chosen arbitrarily by pgoldman 2025-11-18, based on little more than a vague guess. const HTTP_DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); +struct FilteredDnsResolver; + +impl reqwest::dns::Resolve for FilteredDnsResolver { + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { + let host = name.as_str().to_owned(); + Box::pin(async move { + let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?; + let filtered_addrs: Vec = addrs.filter(|addr| !is_blocked_ip(addr.ip())).collect(); + + if filtered_addrs.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "refusing to connect to private or special-purpose addresses", + ) + .into()); + } + + Ok(Box::new(filtered_addrs.into_iter()) as reqwest::dns::Addrs) + }) + } +} + +fn is_blocked_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_blocked_ipv4(ip), + IpAddr::V6(ip) => is_blocked_ipv6(ip), + } +} + +fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, d] = ip.octets(); + + // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#857-877: + // + // Returns [`true`] if this address is part of the Shared Address Space defined in + // [IETF RFC 6598] (`100.64.0.0/10`). + // + // [IETF RFC 6598]: https://tools.ietf.org/html/rfc6598 + let is_shared = a == 100 && (b & 0b1100_0000) == 0b0100_0000; + + // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#879-904: + // + // Returns [`true`] if this address part of the `198.18.0.0/15` range, which is reserved for + // network devices benchmarking. + // + // This range is defined in [IETF RFC 2544] as `192.18.0.0` through + // `198.19.255.255` but [errata 423] corrects it to `198.18.0.0/15`. + // + // [IETF RFC 2544]: https://tools.ietf.org/html/rfc2544 + // [errata 423]: https://www.rfc-editor.org/errata/eid423 + let is_benchmarking = a == 198 && (b & 0b1111_1110) == 18; + + // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#845-850: + // + // Addresses reserved for future protocols (`192.0.0.0/24`). + // `192.0.0.9` and `192.0.0.10` are documented as globally reachable so they're excluded. + let is_special = a == 192 && b == 0 && c == 0 && d != 9 && d != 10; + + // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#857-877: + // + // Returns [`true`] if this address is part of the Shared Address Space defined in + // [IETF RFC 6598] (`100.64.0.0/10`). + // + // [IETF RFC 6598]: https://tools.ietf.org/html/rfc6598 + let is_reserved = (a & 0b1111_0000) == 0b1111_0000; + + ip.is_unspecified() + || ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_multicast() + || ip.is_broadcast() + || ip.is_documentation() + || is_shared + || is_benchmarking + || is_special + || is_reserved +} + +fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { + let segments = ip.segments(); + + if ip.is_unspecified() + || ip.is_loopback() + || ip.is_unique_local() + || ip.is_unicast_link_local() + || ip.is_multicast() + { + return true; + } + + // IPv4-compatible / mapped (`::a.b.c.d` and `::ffff:a.b.c.d`) + if ip.to_ipv4().is_some_and(is_blocked_ipv4) { + return true; + } + + // According to https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#1628-1631: + // 6to4 (`2002::/16`) is not explicitly documented as globally reachable. + if segments[0] == 0x2002 { + let [a, b] = segments[1].to_be_bytes(); + let [c, d] = segments[2].to_be_bytes(); + if is_blocked_ipv4(Ipv4Addr::new(a, b, c, d)) { + return true; + } + } + + // Well-known IPv4/IPv6 translation prefix (`64:ff9b::/96`). + // This is checked explicitly here because std's unstable `Ipv6Addr::is_global` only checks `64:ff9b:1::/48`: + // https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#1609-1610 + if segments[0] == 0x0064 + && segments[1] == 0xff9b + && segments[2] == 0 + && segments[3] == 0 + && segments[4] == 0 + && segments[5] == 0 + { + let [a, b] = segments[6].to_be_bytes(); + let [c, d] = segments[7].to_be_bytes(); + if is_blocked_ipv4(Ipv4Addr::new(a, b, c, d)) { + return true; + } + } + + // Local-use IPv4/IPv6 translation prefix (`64:ff9b:1::/48`). + // std marks this as non-global in its unstable `Ipv6Addr::is_global`: + // https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#1609-1610 + segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 0x0001 +} + /// Unpack `request` and convert it into an [`http::request::Parts`], /// and a [`Duration`] from its `timeout` if supplied. /// @@ -1234,6 +1368,46 @@ mod test { Ok((table_id, index_id)) } + #[test] + fn blocks_private_and_special_ipv4() { + // RFC1918 private + loopback + shared-address-space examples should be blocked. + assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); + assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); + assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))); + assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))); + assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)))); + // A normal public address should remain allowed. + assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); + } + + #[test] + fn blocks_private_and_special_ipv6() { + // Loopback, unique-local, and link-local examples should be blocked. + assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::LOCALHOST))); + assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)))); + assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)))); + // A normal global IPv6 address should remain allowed. + assert!(!is_blocked_ip(IpAddr::V6(Ipv6Addr::new( + 0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111 + )))); + } + + #[test] + fn blocks_ipv6_encodings_of_private_ipv4() { + // IPv4-mapped form of `10.0.0.1`: `::ffff:10.0.0.1`. + assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new( + 0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001 + )))); + // 6to4 form carrying `10.0.0.1`: `2002:0a00:0001::1`. + assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new( + 0x2002, 0x0a00, 0x0001, 0, 0, 0, 0, 1 + )))); + // IPv4/IPv6 translation prefix carrying `10.0.0.1`: `64:ff9b::a00:1`. + assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new( + 0x0064, 0xff9b, 0, 0, 0, 0, 0x0a00, 0x0001 + )))); + } + #[test] fn table_scan_metrics() -> Result<()> { let db = relational_db()?; From 01fe0b281a928b2e2ccd5f45c7be81292d731318 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 9 Feb 2026 17:01:17 -0800 Subject: [PATCH 2/9] smoketest --- crates/smoketests/tests/http_egress.rs | 37 ++++++++++++++++++++++++++ crates/smoketests/tests/mod.rs | 1 + 2 files changed, 38 insertions(+) create mode 100644 crates/smoketests/tests/http_egress.rs diff --git a/crates/smoketests/tests/http_egress.rs b/crates/smoketests/tests/http_egress.rs new file mode 100644 index 00000000000..f43063b6ef2 --- /dev/null +++ b/crates/smoketests/tests/http_egress.rs @@ -0,0 +1,37 @@ +use spacetimedb_smoketests::{require_local_server, Smoketest}; + +const MODULE_CODE_HTTP_DISALLOWED_IP: &str = r#" +use spacetimedb::ProcedureContext; + +#[spacetimedb::procedure] +pub fn request_disallowed_ip(ctx: &mut ProcedureContext) -> Result<(), String> { + match ctx.http.get("http://127.0.0.1:80/") { + Ok(_) => Err("request unexpectedly succeeded".to_owned()), + Err(err) => { + let message = err.to_string(); + if message.contains("refusing to connect to private or special-purpose addresses") { + Ok(()) + } else { + Err(format!("unexpected error from http request: {message}")) + } + } + } +} +"#; + +#[test] +fn test_http_disallowed_ip_is_blocked() { + require_local_server!(); + + let test = Smoketest::builder().module_code(MODULE_CODE_HTTP_DISALLOWED_IP).build(); + + let output = test.call_output("request_disallowed_ip", &[]); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "Expected request_disallowed_ip to succeed after observing blocked egress error.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr + ); +} diff --git a/crates/smoketests/tests/mod.rs b/crates/smoketests/tests/mod.rs index 256de070f7d..358436c0ea1 100644 --- a/crates/smoketests/tests/mod.rs +++ b/crates/smoketests/tests/mod.rs @@ -18,6 +18,7 @@ pub mod domains; pub mod energy; pub mod fail_initial_publish; pub mod filtering; +pub mod http_egress; pub mod module_nested_op; pub mod modules; pub mod namespaces; From e024b5724afa6cfa4a2a83326bc368808806f514 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 9 Feb 2026 19:52:53 -0800 Subject: [PATCH 3/9] add flag for tests --- crates/core/Cargo.toml | 3 +++ crates/core/src/host/instance_env.rs | 13 ++++++------- crates/standalone/Cargo.toml | 1 + crates/testing/Cargo.toml | 3 +++ sdks/rust/Cargo.toml | 3 +++ tools/ci/src/main.rs | 16 ++++++++++++++++ 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 3ec0b44e251..b1a249d1d88 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -137,6 +137,9 @@ nix = { workspace = true, features = ["sched"] } # Print a warning when doing an unindexed `iter_by_col_range` on a large table. unindexed_iter_by_col_range_warn = [] default = ["unindexed_iter_by_col_range_warn"] +# Test-only escape hatch used by SDK procedure tests that intentionally call `localhost`. +# Keep this off in production builds. +allow_loopback_http_for_tests = [] # Enable timing for wasm ABI calls spacetimedb-wasm-instance-env-times = [] # Enable test helpers and utils diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 2a8bf115ed4..763ed51ca55 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -974,6 +974,8 @@ fn is_blocked_ip(ip: IpAddr) -> bool { fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { let [a, b, c, d] = ip.octets(); + let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); + let is_loopback = ip.is_loopback() && block_loopback; // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#857-877: // @@ -1011,11 +1013,11 @@ fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { ip.is_unspecified() || ip.is_private() - || ip.is_loopback() || ip.is_link_local() || ip.is_multicast() || ip.is_broadcast() || ip.is_documentation() + || is_loopback || is_shared || is_benchmarking || is_special @@ -1024,13 +1026,10 @@ fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { let segments = ip.segments(); + let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); + let is_loopback = ip.is_loopback() && block_loopback; - if ip.is_unspecified() - || ip.is_loopback() - || ip.is_unique_local() - || ip.is_unicast_link_local() - || ip.is_multicast() - { + if ip.is_unspecified() || ip.is_unique_local() || ip.is_unicast_link_local() || ip.is_multicast() || is_loopback { return true; } diff --git a/crates/standalone/Cargo.toml b/crates/standalone/Cargo.toml index f91ef6d537a..0ce65a57ed0 100644 --- a/crates/standalone/Cargo.toml +++ b/crates/standalone/Cargo.toml @@ -18,6 +18,7 @@ required-features = [] # Features required to build this target (N/A for lib) [features] unstable = ["spacetimedb-client-api/unstable"] +allow_loopback_http_for_tests = ["spacetimedb-core/allow_loopback_http_for_tests"] # Perfmaps for profiling modules perfmap = ["spacetimedb-core/perfmap"] # Disables core pinning diff --git a/crates/testing/Cargo.toml b/crates/testing/Cargo.toml index 8d93673acd0..5622c66689b 100644 --- a/crates/testing/Cargo.toml +++ b/crates/testing/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license-file = "LICENSE" publish = false +[features] +allow_loopback_http_for_tests = ["spacetimedb-standalone/allow_loopback_http_for_tests"] + [dependencies] spacetimedb-cli.workspace = true spacetimedb-data-structures.workspace = true diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml index 421a80d10ee..e1915f98d76 100644 --- a/sdks/rust/Cargo.toml +++ b/sdks/rust/Cargo.toml @@ -8,6 +8,9 @@ rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +allow_loopback_http_for_tests = ["spacetimedb-testing/allow_loopback_http_for_tests"] + [dependencies] spacetimedb-data-structures.workspace = true spacetimedb-sats.workspace = true diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 308cab38929..a108ed9a538 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -311,6 +311,22 @@ fn main() -> Result<()> { "--all", "--exclude", "spacetimedb-smoketests", + "--exclude", + "spacetimedb-sdk", + "--", + "--test-threads=2", + "--skip", + "unreal" + ) + .run()?; + // SDK procedure tests intentionally make localhost HTTP requests. + cmd!( + "cargo", + "test", + "-p", + "spacetimedb-sdk", + "--features", + "allow_loopback_http_for_tests", "--", "--test-threads=2", "--skip", From 792f5cf887d01cd7ca1c16d895eb2d08aa715b78 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Feb 2026 08:20:07 -0800 Subject: [PATCH 4/9] Apply RFC6890 for IPv4 ranges --- crates/core/src/host/instance_env.rs | 295 ++++++++++++++++++++++----- 1 file changed, 245 insertions(+), 50 deletions(-) diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 763ed51ca55..4462911f2b9 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -975,53 +975,61 @@ fn is_blocked_ip(ip: IpAddr) -> bool { fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { let [a, b, c, d] = ip.octets(); let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); - let is_loopback = ip.is_loopback() && block_loopback; - - // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#857-877: - // - // Returns [`true`] if this address is part of the Shared Address Space defined in - // [IETF RFC 6598] (`100.64.0.0/10`). - // - // [IETF RFC 6598]: https://tools.ietf.org/html/rfc6598 - let is_shared = a == 100 && (b & 0b1100_0000) == 0b0100_0000; - - // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#879-904: - // - // Returns [`true`] if this address part of the `198.18.0.0/15` range, which is reserved for - // network devices benchmarking. - // - // This range is defined in [IETF RFC 2544] as `192.18.0.0` through - // `198.19.255.255` but [errata 423] corrects it to `198.18.0.0/15`. - // - // [IETF RFC 2544]: https://tools.ietf.org/html/rfc2544 - // [errata 423]: https://www.rfc-editor.org/errata/eid423 + // RFC 6890 Section 2.2.2, Table 1: "This host on this network" (0.0.0.0/8). + let is_this_host_on_this_network = a == 0; + // RFC 6890 Section 2.2.2, Table 2: "Private-Use" (10.0.0.0/8). + let is_private_use_10 = a == 10; + // RFC 6890 Section 2.2.2, Table 3: "Shared Address Space" (100.64.0.0/10). + let is_shared_address_space = a == 100 && (b & 0b1100_0000) == 0b0100_0000; + // RFC 6890 Section 2.2.2, Table 4: "Loopback" (127.0.0.0/8). + let is_loopback = block_loopback && a == 127; + // RFC 6890 Section 2.2.2, Table 5: "Link Local" (169.254.0.0/16). + let is_link_local = a == 169 && b == 254; + // RFC 6890 Section 2.2.2, Table 6: "Private-Use" (172.16.0.0/12). + let is_private_use_172 = a == 172 && (b & 0b1111_0000) == 16; + // RFC 6890 Section 2.2.2, Table 7: "IETF Protocol Assignments" (192.0.0.0/24). + let is_ietf_protocol_assignments = a == 192 && b == 0 && c == 0; + // RFC 6890 Section 2.2.2, Table 8: "Documentation (TEST-NET-1)" (192.0.2.0/24). + let is_test_net_1 = a == 192 && b == 0 && c == 2; + // RFC 6890 Section 2.2.2, Table 9: "AS112-v4" (192.31.196.0/24). + let is_as112_v4 = a == 192 && b == 31 && c == 196; + // RFC 6890 Section 2.2.2, Table 10: "AMT" (192.52.193.0/24). + let is_amt = a == 192 && b == 52 && c == 193; + // RFC 6890 Section 2.2.2, Table 11: "6to4 Relay Anycast" (192.88.99.0/24). + let is_6to4_relay_anycast = a == 192 && b == 88 && c == 99; + // RFC 6890 Section 2.2.2, Table 12: "Private-Use" (192.168.0.0/16). + let is_private_use_192 = a == 192 && b == 168; + // RFC 6890 Section 2.2.2, Table 13: "Direct Delegation AS112 Service" (192.175.48.0/24). + let is_direct_delegation_as112_service = a == 192 && b == 175 && c == 48; + // RFC 6890 Section 2.2.2, Table 14: "Benchmarking" (198.18.0.0/15). let is_benchmarking = a == 198 && (b & 0b1111_1110) == 18; - - // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#845-850: - // - // Addresses reserved for future protocols (`192.0.0.0/24`). - // `192.0.0.9` and `192.0.0.10` are documented as globally reachable so they're excluded. - let is_special = a == 192 && b == 0 && c == 0 && d != 9 && d != 10; - - // Taken directly from https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#857-877: - // - // Returns [`true`] if this address is part of the Shared Address Space defined in - // [IETF RFC 6598] (`100.64.0.0/10`). - // - // [IETF RFC 6598]: https://tools.ietf.org/html/rfc6598 + // RFC 6890 Section 2.2.2, Table 15: "Documentation (TEST-NET-2)" (198.51.100.0/24). + let is_test_net_2 = a == 198 && b == 51 && c == 100; + // RFC 6890 Section 2.2.2, Table 16: "Documentation (TEST-NET-3)" (203.0.113.0/24). + let is_test_net_3 = a == 203 && b == 0 && c == 113; + // RFC 6890 Section 2.2.2, Table 17: "Reserved" (240.0.0.0/4). let is_reserved = (a & 0b1111_0000) == 0b1111_0000; + // RFC 6890 Section 2.2.2, Table 18: "Limited Broadcast" (255.255.255.255/32). + let is_limited_broadcast = a == 255 && b == 255 && c == 255 && d == 255; - ip.is_unspecified() - || ip.is_private() - || ip.is_link_local() - || ip.is_multicast() - || ip.is_broadcast() - || ip.is_documentation() + is_this_host_on_this_network + || is_private_use_10 + || is_shared_address_space || is_loopback - || is_shared + || is_link_local + || is_private_use_172 + || is_ietf_protocol_assignments + || is_test_net_1 + || is_as112_v4 + || is_amt + || is_6to4_relay_anycast + || is_private_use_192 + || is_direct_delegation_as112_service || is_benchmarking - || is_special + || is_test_net_2 + || is_test_net_3 || is_reserved + || is_limited_broadcast } fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { @@ -1368,15 +1376,202 @@ mod test { } #[test] - fn blocks_private_and_special_ipv4() { - // RFC1918 private + loopback + shared-address-space examples should be blocked. - assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); - assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); - assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))); - assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))); - assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)))); - // A normal public address should remain allowed. - assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); + fn blocks_each_rfc6890_ipv4_range() { + // RFC 6890 §2.2.2 tables 1-18. + let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); + let cases = [ + // Table 1: This host on this network (0.0.0.0/8). + (Ipv4Addr::new(0, 0, 0, 1), true), + // Table 2: Private-Use (10.0.0.0/8). + (Ipv4Addr::new(10, 255, 255, 255), true), + // Table 3: Shared Address Space (100.64.0.0/10). + (Ipv4Addr::new(100, 127, 255, 255), true), + // Table 4: Loopback (127.0.0.0/8). + (Ipv4Addr::new(127, 0, 0, 1), block_loopback), + // Table 5: Link Local (169.254.0.0/16). + (Ipv4Addr::new(169, 254, 255, 255), true), + // Table 6: Private-Use (172.16.0.0/12). + (Ipv4Addr::new(172, 31, 255, 255), true), + // Table 7: IETF Protocol Assignments (192.0.0.0/24). + (Ipv4Addr::new(192, 0, 0, 255), true), + // Table 8: Documentation (TEST-NET-1) (192.0.2.0/24). + (Ipv4Addr::new(192, 0, 2, 1), true), + // Table 9: AS112-v4 (192.31.196.0/24). + (Ipv4Addr::new(192, 31, 196, 1), true), + // Table 10: AMT (192.52.193.0/24). + (Ipv4Addr::new(192, 52, 193, 1), true), + // Table 11: 6to4 Relay Anycast (192.88.99.0/24). + (Ipv4Addr::new(192, 88, 99, 1), true), + // Table 12: Private-Use (192.168.0.0/16). + (Ipv4Addr::new(192, 168, 255, 255), true), + // Table 13: Direct Delegation AS112 Service (192.175.48.0/24). + (Ipv4Addr::new(192, 175, 48, 1), true), + // Table 14: Benchmarking (198.18.0.0/15). + (Ipv4Addr::new(198, 19, 255, 255), true), + // Table 15: Documentation (TEST-NET-2) (198.51.100.0/24). + (Ipv4Addr::new(198, 51, 100, 1), true), + // Table 16: Documentation (TEST-NET-3) (203.0.113.0/24). + (Ipv4Addr::new(203, 0, 113, 1), true), + // Table 17: Reserved (240.0.0.0/4). + (Ipv4Addr::new(240, 0, 0, 1), true), + // Table 18: Limited Broadcast (255.255.255.255/32). + (Ipv4Addr::new(255, 255, 255, 255), true), + ]; + + for (addr, expected_blocked) in cases { + assert_eq!( + is_blocked_ip(IpAddr::V4(addr)), + expected_blocked, + "unexpected block decision for {addr}" + ); + } + } + + #[test] + fn blocks_rfc6890_ipv4_range_endpoints() { + // RFC 6890 §2.2.2 tables 1-18, checked at each range's low/high endpoints. + let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); + let ranges = [ + // Table 1: This host on this network (0.0.0.0/8). + ( + "this-host-on-this-network", + Ipv4Addr::new(0, 0, 0, 0), + Ipv4Addr::new(0, 255, 255, 255), + true, + ), + // Table 2: Private-Use (10.0.0.0/8). + ( + "private-use-10", + Ipv4Addr::new(10, 0, 0, 0), + Ipv4Addr::new(10, 255, 255, 255), + true, + ), + // Table 3: Shared Address Space (100.64.0.0/10). + ( + "shared-address-space", + Ipv4Addr::new(100, 64, 0, 0), + Ipv4Addr::new(100, 127, 255, 255), + true, + ), + // Table 4: Loopback (127.0.0.0/8). + ( + "loopback", + Ipv4Addr::new(127, 0, 0, 0), + Ipv4Addr::new(127, 255, 255, 255), + block_loopback, + ), + // Table 5: Link Local (169.254.0.0/16). + ( + "link-local", + Ipv4Addr::new(169, 254, 0, 0), + Ipv4Addr::new(169, 254, 255, 255), + true, + ), + // Table 6: Private-Use (172.16.0.0/12). + ( + "private-use-172", + Ipv4Addr::new(172, 16, 0, 0), + Ipv4Addr::new(172, 31, 255, 255), + true, + ), + // Table 7: IETF Protocol Assignments (192.0.0.0/24). + ( + "ietf-protocol-assignments", + Ipv4Addr::new(192, 0, 0, 0), + Ipv4Addr::new(192, 0, 0, 255), + true, + ), + // Table 8: Documentation (TEST-NET-1) (192.0.2.0/24). + ( + "test-net-1", + Ipv4Addr::new(192, 0, 2, 0), + Ipv4Addr::new(192, 0, 2, 255), + true, + ), + // Table 9: AS112-v4 (192.31.196.0/24). + ( + "as112-v4", + Ipv4Addr::new(192, 31, 196, 0), + Ipv4Addr::new(192, 31, 196, 255), + true, + ), + // Table 10: AMT (192.52.193.0/24). + ( + "amt", + Ipv4Addr::new(192, 52, 193, 0), + Ipv4Addr::new(192, 52, 193, 255), + true, + ), + // Table 11: 6to4 Relay Anycast (192.88.99.0/24). + ( + "6to4-relay-anycast", + Ipv4Addr::new(192, 88, 99, 0), + Ipv4Addr::new(192, 88, 99, 255), + true, + ), + // Table 12: Private-Use (192.168.0.0/16). + ( + "private-use-192", + Ipv4Addr::new(192, 168, 0, 0), + Ipv4Addr::new(192, 168, 255, 255), + true, + ), + // Table 13: Direct Delegation AS112 Service (192.175.48.0/24). + ( + "direct-delegation-as112-service", + Ipv4Addr::new(192, 175, 48, 0), + Ipv4Addr::new(192, 175, 48, 255), + true, + ), + // Table 14: Benchmarking (198.18.0.0/15). + ( + "benchmarking", + Ipv4Addr::new(198, 18, 0, 0), + Ipv4Addr::new(198, 19, 255, 255), + true, + ), + // Table 15: Documentation (TEST-NET-2) (198.51.100.0/24). + ( + "test-net-2", + Ipv4Addr::new(198, 51, 100, 0), + Ipv4Addr::new(198, 51, 100, 255), + true, + ), + // Table 16: Documentation (TEST-NET-3) (203.0.113.0/24). + ( + "test-net-3", + Ipv4Addr::new(203, 0, 113, 0), + Ipv4Addr::new(203, 0, 113, 255), + true, + ), + // Table 17: Reserved (240.0.0.0/4). + ( + "reserved", + Ipv4Addr::new(240, 0, 0, 0), + Ipv4Addr::new(255, 255, 255, 255), + true, + ), + // Table 18: Limited Broadcast (255.255.255.255/32). + ( + "limited-broadcast", + Ipv4Addr::new(255, 255, 255, 255), + Ipv4Addr::new(255, 255, 255, 255), + true, + ), + ]; + + for (name, low, high, expected_blocked) in ranges { + assert_eq!( + is_blocked_ip(IpAddr::V4(low)), + expected_blocked, + "{name}: unexpected decision for low endpoint {low}" + ); + assert_eq!( + is_blocked_ip(IpAddr::V4(high)), + expected_blocked, + "{name}: unexpected decision for high endpoint {high}" + ); + } } #[test] From 8595bb73a4d577d3fcf7d7e31ec795c228db0de9 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Feb 2026 08:56:14 -0800 Subject: [PATCH 5/9] Apply RFC6890 for IPv6 ranges --- crates/core/src/host/instance_env.rs | 380 ++++++++++++++++++++++++--- 1 file changed, 342 insertions(+), 38 deletions(-) diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 4462911f2b9..f5475257ab5 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -1035,48 +1035,74 @@ fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { let segments = ip.segments(); let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); - let is_loopback = ip.is_loopback() && block_loopback; - - if ip.is_unspecified() || ip.is_unique_local() || ip.is_unicast_link_local() || ip.is_multicast() || is_loopback { - return true; - } - - // IPv4-compatible / mapped (`::a.b.c.d` and `::ffff:a.b.c.d`) - if ip.to_ipv4().is_some_and(is_blocked_ipv4) { - return true; - } - - // According to https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#1628-1631: - // 6to4 (`2002::/16`) is not explicitly documented as globally reachable. - if segments[0] == 0x2002 { - let [a, b] = segments[1].to_be_bytes(); - let [c, d] = segments[2].to_be_bytes(); - if is_blocked_ipv4(Ipv4Addr::new(a, b, c, d)) { - return true; - } - } - - // Well-known IPv4/IPv6 translation prefix (`64:ff9b::/96`). - // This is checked explicitly here because std's unstable `Ipv6Addr::is_global` only checks `64:ff9b:1::/48`: - // https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#1609-1610 - if segments[0] == 0x0064 + // RFC 6890 Section 2.2.3, Table 17: "Loopback Address" (::1/128). + let is_loopback_address = block_loopback && ip == Ipv6Addr::LOCALHOST; + // RFC 6890 Section 2.2.3, Table 18: "Unspecified Address" (::/128). + let is_unspecified_address = ip.is_unspecified(); + // RFC 6890 Section 2.2.3, Table 19: "IPv4-IPv6 Translat." (64:ff9b::/96). + let is_ipv4_ipv6_translation = segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 0 && segments[3] == 0 && segments[4] == 0 - && segments[5] == 0 - { - let [a, b] = segments[6].to_be_bytes(); - let [c, d] = segments[7].to_be_bytes(); - if is_blocked_ipv4(Ipv4Addr::new(a, b, c, d)) { - return true; - } - } - - // Local-use IPv4/IPv6 translation prefix (`64:ff9b:1::/48`). - // std marks this as non-global in its unstable `Ipv6Addr::is_global`: - // https://doc.rust-lang.org/nightly/src/core/net/ip_addr.rs.html#1609-1610 - segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 0x0001 + && segments[5] == 0; + // IANA IPv6 Special-Purpose Address Space: "IPv4-IPv6 Translat." (64:ff9b:1::/48), RFC 8215. + let is_ipv4_ipv6_translation_local_use = segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 0x0001; + // RFC 6890 Section 2.2.3, Table 20: "IPv4-mapped Address" (::ffff:0:0/96). + let is_ipv4_mapped = segments[0] == 0 + && segments[1] == 0 + && segments[2] == 0 + && segments[3] == 0 + && segments[4] == 0 + && segments[5] == 0xffff; + // RFC 6890 Section 2.2.3, Table 21: "Discard-Only Address Block" (100::/64). + let is_discard_only_prefix = segments[0] == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0; + // IANA IPv6 Special-Purpose Address Space: "Dummy IPv6 Prefix" (100:0:0:1::/64), RFC 9780. + let is_dummy_ipv6_prefix = segments[0] == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 1; + // RFC 6890 Section 2.2.3, Table 22: "IETF Protocol Assignments" (2001::/23). + // This broader prefix also covers newer IANA entries including: + // `2001:1::1/128`, `2001:1::2/128`, `2001:1::3/128`, `2001:3::/32`, + // `2001:4:112::/48`, `2001:20::/28`, and `2001:30::/28`. + let is_ietf_protocol_assignments = segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0; + // RFC 6890 Section 2.2.3, Table 23: "TEREDO" (2001::/32). + let is_teredo = segments[0] == 0x2001 && segments[1] == 0; + // RFC 6890 Section 2.2.3, Table 24: "Benchmarking" (2001:2::/48). + let is_benchmarking = segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0; + // RFC 6890 Section 2.2.3, Table 25: "Documentation" (2001:db8::/32). + let is_documentation = segments[0] == 0x2001 && segments[1] == 0x0db8; + // RFC 6890 Section 2.2.3, Table 26: "ORCHID" (2001:10::/28). + let is_orchid = segments[0] == 0x2001 && (segments[1] & 0xfff0) == 0x0010; + // RFC 6890 Section 2.2.3, Table 27: "6to4" (2002::/16). + let is_6to4 = segments[0] == 0x2002; + // IANA IPv6 Special-Purpose Address Space: "Direct Delegation AS112 Service" (2620:4f:8000::/48), RFC 7534. + let is_direct_delegation_as112_service = segments[0] == 0x2620 && segments[1] == 0x004f && segments[2] == 0x8000; + // IANA IPv6 Special-Purpose Address Space: "Documentation" (3fff::/20), RFC 9637. + let is_documentation_3fff = segments[0] == 0x3fff && (segments[1] & 0xf000) == 0; + // IANA IPv6 Special-Purpose Address Space: "Segment Routing (SRv6) SIDs" (5f00::/16), RFC 9602. + let is_segment_routing_srv6_sids = segments[0] == 0x5f00; + // RFC 6890 Section 2.2.3, Table 28: "Unique-Local" (fc00::/7). + let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; + // RFC 6890 Section 2.2.3, Table 29: "Linked-Scoped Unicast" (fe80::/10). + let is_link_scoped_unicast = (segments[0] & 0xffc0) == 0xfe80; + + is_loopback_address + || is_unspecified_address + || is_ipv4_ipv6_translation + || is_ipv4_ipv6_translation_local_use + || is_ipv4_mapped + || is_discard_only_prefix + || is_dummy_ipv6_prefix + || is_ietf_protocol_assignments + || is_teredo + || is_benchmarking + || is_documentation + || is_orchid + || is_6to4 + || is_direct_delegation_as112_service + || is_documentation_3fff + || is_segment_routing_srv6_sids + || is_unique_local + || is_link_scoped_unicast } /// Unpack `request` and convert it into an [`http::request::Parts`], @@ -1574,6 +1600,284 @@ mod test { } } + #[test] + fn blocks_each_rfc6890_ipv6_range() { + // RFC 6890 §2.2.3 tables 17-29. + let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); + let cases = [ + // Table 17: Loopback Address (::1/128). + (Ipv6Addr::LOCALHOST, block_loopback), + // Table 18: Unspecified Address (::/128). + (Ipv6Addr::UNSPECIFIED, true), + // Table 19: IPv4-IPv6 Translat. (64:ff9b::/96). + (Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0xc000, 0x0201), true), + // Table 20: IPv4-mapped Address (::ffff:0:0/96). + (Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0808, 0x0808), true), + // Table 21: Discard-Only Address Block (100::/64). + (Ipv6Addr::new(0x0100, 0, 0, 0, 0, 0, 0, 1), true), + // Table 22: IETF Protocol Assignments (2001::/23). + (Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 1), true), + // Table 23: TEREDO (2001::/32). + (Ipv6Addr::new(0x2001, 0x0000, 0, 0, 0, 0, 0, 1), true), + // Table 24: Benchmarking (2001:2::/48). + (Ipv6Addr::new(0x2001, 0x0002, 0x0000, 0, 0, 0, 0, 1), true), + // Table 25: Documentation (2001:db8::/32). + (Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), true), + // Table 26: ORCHID (2001:10::/28). + (Ipv6Addr::new(0x2001, 0x0010, 0, 0, 0, 0, 0, 1), true), + // Table 27: 6to4 (2002::/16). + (Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 1), true), + // Table 28: Unique-Local (fc00::/7). + (Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1), true), + // Table 29: Linked-Scoped Unicast (fe80::/10). + (Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1), true), + ]; + + for (addr, expected_blocked) in cases { + assert_eq!( + is_blocked_ip(IpAddr::V6(addr)), + expected_blocked, + "unexpected block decision for {addr}" + ); + } + // A normal global IPv6 address should remain allowed. + assert!(!is_blocked_ip(IpAddr::V6(Ipv6Addr::new( + 0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111 + )))); + } + + #[test] + fn blocks_rfc6890_ipv6_range_endpoints() { + // RFC 6890 §2.2.3 tables 17-29, checked at each range's low/high endpoints. + let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); + let ranges = [ + // Table 17: Loopback Address (::1/128). + ( + "loopback-address", + Ipv6Addr::LOCALHOST, + Ipv6Addr::LOCALHOST, + block_loopback, + ), + // Table 18: Unspecified Address (::/128). + ( + "unspecified-address", + Ipv6Addr::UNSPECIFIED, + Ipv6Addr::UNSPECIFIED, + true, + ), + // Table 19: IPv4-IPv6 Translat. (64:ff9b::/96). + ( + "ipv4-ipv6-translation", + Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0xffff, 0xffff), + true, + ), + // Table 20: IPv4-mapped Address (::ffff:0:0/96). + ( + "ipv4-mapped-address", + Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0, 0), + Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 21: Discard-Only Address Block (100::/64). + ( + "discard-only-address-block", + Ipv6Addr::new(0x0100, 0, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x0100, 0, 0, 0, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 22: IETF Protocol Assignments (2001::/23). + ( + "ietf-protocol-assignments", + Ipv6Addr::new(0x2001, 0x0000, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x01ff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 23: TEREDO (2001::/32). + ( + "teredo", + Ipv6Addr::new(0x2001, 0x0000, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x0000, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 24: Benchmarking (2001:2::/48). + ( + "benchmarking", + Ipv6Addr::new(0x2001, 0x0002, 0x0000, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x0002, 0x0000, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 25: Documentation (2001:db8::/32). + ( + "documentation", + Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x0db8, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 26: ORCHID (2001:10::/28). + ( + "orchid", + Ipv6Addr::new(0x2001, 0x0010, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x001f, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 27: 6to4 (2002::/16). + ( + "6to4", + Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2002, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 28: Unique-Local (fc00::/7). + ( + "unique-local", + Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0xfdff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + // Table 29: Linked-Scoped Unicast (fe80::/10). + ( + "linked-scoped-unicast", + Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0xfebf, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + true, + ), + ]; + + for (name, low, high, expected_blocked) in ranges { + assert_eq!( + is_blocked_ip(IpAddr::V6(low)), + expected_blocked, + "{name}: unexpected decision for low endpoint {low}" + ); + assert_eq!( + is_blocked_ip(IpAddr::V6(high)), + expected_blocked, + "{name}: unexpected decision for high endpoint {high}" + ); + } + } + + #[test] + fn blocks_each_additional_iana_ipv6_range() { + // Additional ranges listed in the IANA IPv6 Special-Purpose Address Space registry. + // Some are transitively covered by the broader `2001::/23` block, and are kept + // here intentionally as explicit regression checks. + let cases = [ + // IPv4-IPv6 Translat. local-use prefix (`64:ff9b:1::/48`). + Ipv6Addr::new(0x0064, 0xff9b, 0x0001, 0, 0, 0, 0, 1), + // Dummy IPv6 Prefix (`100:0:0:1::/64`). + Ipv6Addr::new(0x0100, 0, 0, 1, 0, 0, 0, 1), + // Port Control Protocol Anycast (`2001:1::1/128`). + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 1), + // Traversal Using Relays around NAT Anycast (`2001:1::2/128`). + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 2), + // DNS-SD Service Registration Protocol Anycast (`2001:1::3/128`). + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 3), + // AMT (`2001:3::/32`). + Ipv6Addr::new(0x2001, 0x0003, 0, 0, 0, 0, 0, 1), + // AS112-v6 (`2001:4:112::/48`). + Ipv6Addr::new(0x2001, 0x0004, 0x0112, 0, 0, 0, 0, 1), + // ORCHIDv2 (`2001:20::/28`). + Ipv6Addr::new(0x2001, 0x0020, 0, 0, 0, 0, 0, 1), + // Drone Remote ID Protocol Entity Tags (DETs) Prefix (`2001:30::/28`). + Ipv6Addr::new(0x2001, 0x0030, 0, 0, 0, 0, 0, 1), + // Direct Delegation AS112 Service (`2620:4f:8000::/48`). + Ipv6Addr::new(0x2620, 0x004f, 0x8000, 0, 0, 0, 0, 1), + // Documentation (`3fff::/20`). + Ipv6Addr::new(0x3fff, 0x0001, 0, 0, 0, 0, 0, 1), + // Segment Routing (SRv6) SIDs (`5f00::/16`). + Ipv6Addr::new(0x5f00, 0, 0, 0, 0, 0, 0, 1), + ]; + + for addr in cases { + assert!( + is_blocked_ip(IpAddr::V6(addr)), + "expected additional IANA IPv6 range to be blocked: {addr}" + ); + } + } + + #[test] + fn blocks_additional_iana_ipv6_range_endpoints() { + // Additional ranges listed in the IANA IPv6 Special-Purpose Address Space registry. + // Some are transitively covered by the broader `2001::/23` block, and are kept + // here intentionally as explicit regression checks. + let ranges = [ + ( + "ipv4-ipv6-translation-local-use", + Ipv6Addr::new(0x0064, 0xff9b, 0x0001, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x0064, 0xff9b, 0x0001, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ( + "dummy-ipv6-prefix", + Ipv6Addr::new(0x0100, 0, 0, 1, 0, 0, 0, 0), + Ipv6Addr::new(0x0100, 0, 0, 1, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ( + "pcp-anycast", + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 1), + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 1), + ), + ( + "turn-anycast", + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 2), + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 2), + ), + ( + "dns-sd-srp-anycast", + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 3), + Ipv6Addr::new(0x2001, 0x0001, 0, 0, 0, 0, 0, 3), + ), + ( + "amt", + Ipv6Addr::new(0x2001, 0x0003, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x0003, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ( + "as112-v6", + Ipv6Addr::new(0x2001, 0x0004, 0x0112, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x0004, 0x0112, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ( + "orchidv2", + Ipv6Addr::new(0x2001, 0x0020, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x002f, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ( + "drone-remote-id-dets-prefix", + Ipv6Addr::new(0x2001, 0x0030, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2001, 0x003f, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ( + "direct-delegation-as112-service", + Ipv6Addr::new(0x2620, 0x004f, 0x8000, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x2620, 0x004f, 0x8000, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ( + "documentation-3fff", + Ipv6Addr::new(0x3fff, 0x0000, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x3fff, 0x0fff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ( + "segment-routing-srv6-sids", + Ipv6Addr::new(0x5f00, 0x0000, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0x5f00, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff), + ), + ]; + + for (name, low, high) in ranges { + assert!( + is_blocked_ip(IpAddr::V6(low)), + "{name}: unexpected decision for low endpoint {low}" + ); + assert!( + is_blocked_ip(IpAddr::V6(high)), + "{name}: unexpected decision for high endpoint {high}" + ); + } + } + #[test] fn blocks_private_and_special_ipv6() { // Loopback, unique-local, and link-local examples should be blocked. From 03026a42284cdfa2840ca1e3aa2e60090a1389f5 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Feb 2026 09:00:02 -0800 Subject: [PATCH 6/9] remove redundant test --- crates/core/src/host/instance_env.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index f5475257ab5..bbaa62ab559 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -1759,7 +1759,7 @@ mod test { } #[test] - fn blocks_each_additional_iana_ipv6_range() { + fn blocks_additional_iana_ipv6_range() { // Additional ranges listed in the IANA IPv6 Special-Purpose Address Space registry. // Some are transitively covered by the broader `2001::/23` block, and are kept // here intentionally as explicit regression checks. @@ -1878,18 +1878,6 @@ mod test { } } - #[test] - fn blocks_private_and_special_ipv6() { - // Loopback, unique-local, and link-local examples should be blocked. - assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::LOCALHOST))); - assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)))); - assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)))); - // A normal global IPv6 address should remain allowed. - assert!(!is_blocked_ip(IpAddr::V6(Ipv6Addr::new( - 0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111 - )))); - } - #[test] fn blocks_ipv6_encodings_of_private_ipv4() { // IPv4-mapped form of `10.0.0.1`: `::ffff:10.0.0.1`. From 3ab1c3f516be3cdb78ea44f21e50667f8a3c9ed1 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Feb 2026 12:00:59 -0800 Subject: [PATCH 7/9] allow loopback in csharp testsuite --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69441f9afa5..86402b98934 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -903,7 +903,7 @@ jobs: export CARGO_HOME="$HOME/.cargo" echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" cargo install --force --path crates/cli --locked --message-format=short - cargo install --force --path crates/standalone --locked --message-format=short + cargo install --force --path crates/standalone --features allow_loopback_http_for_tests --locked --message-format=short # Add a handy alias using the old binary name, so that we don't have to rewrite all scripts (incl. in submodules). ln -sf $CARGO_HOME/bin/spacetimedb-cli $CARGO_HOME/bin/spacetime From f6906b133da23c6b87c046a8ab503f1954b041bd Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Feb 2026 15:01:30 -0800 Subject: [PATCH 8/9] proper literal and redirect handling --- crates/core/src/host/instance_env.rs | 56 ++++++++++++++++-- crates/guard/src/lib.rs | 2 +- crates/smoketests/DEVELOP.md | 4 +- crates/smoketests/tests/http_egress.rs | 79 +++++++++++++++++++++----- tools/ci/src/smoketest.rs | 2 + 5 files changed, 120 insertions(+), 23 deletions(-) diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index bbaa62ab559..6f57efa15e0 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -868,12 +868,26 @@ impl InstanceEnv { let reqwest = reqwest; + // Check if we have a blocked IP address, since IP literals bypass DNS resolution. + if is_blocked_ip_literal(reqwest.url()) { + return Err(http_error(BLOCKED_HTTP_ADDRESS_ERROR)); + } + + let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { + if is_blocked_ip_literal(attempt.url()) { + attempt.error(BLOCKED_HTTP_ADDRESS_ERROR) + } else { + reqwest::redirect::Policy::default().redirect(attempt) + } + }); + // TODO(procedure-metrics): record size in bytes of response, time spent awaiting response. // Actually execute the HTTP request! // TODO(perf): Stash a long-lived `Client` in the env somewhere, rather than building a new one for each call. let execute_fut = reqwest::Client::builder() .dns_resolver(Arc::new(FilteredDnsResolver)) + .redirect(redirect_policy) .build() .map_err(http_error)? .execute(reqwest); @@ -942,6 +956,7 @@ impl InstanceEnv { /// /// Value chosen arbitrarily by pgoldman 2025-11-18, based on little more than a vague guess. const HTTP_DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); +const BLOCKED_HTTP_ADDRESS_ERROR: &str = "refusing to connect to private or special-purpose addresses"; struct FilteredDnsResolver; @@ -953,11 +968,9 @@ impl reqwest::dns::Resolve for FilteredDnsResolver { let filtered_addrs: Vec = addrs.filter(|addr| !is_blocked_ip(addr.ip())).collect(); if filtered_addrs.is_empty() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "refusing to connect to private or special-purpose addresses", - ) - .into()); + return Err( + std::io::Error::new(std::io::ErrorKind::PermissionDenied, BLOCKED_HTTP_ADDRESS_ERROR).into(), + ); } Ok(Box::new(filtered_addrs.into_iter()) as reqwest::dns::Addrs) @@ -965,6 +978,14 @@ impl reqwest::dns::Resolve for FilteredDnsResolver { } } +fn is_blocked_ip_literal(url: &reqwest::Url) -> bool { + match url.host() { + Some(url::Host::Ipv4(ip)) => is_blocked_ip(IpAddr::V4(ip)), + Some(url::Host::Ipv6(ip)) => is_blocked_ip(IpAddr::V6(ip)), + Some(url::Host::Domain(_)) | None => false, + } +} + fn is_blocked_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => is_blocked_ipv4(ip), @@ -1453,6 +1474,31 @@ mod test { } } + #[test] + fn blocks_ip_literal_hosts_in_urls() { + let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); + assert_eq!( + is_blocked_ip_literal(&reqwest::Url::parse("http://127.0.0.1:80/").unwrap()), + block_loopback + ); + assert_eq!( + is_blocked_ip_literal(&reqwest::Url::parse("http://[::1]:80/").unwrap()), + block_loopback + ); + assert!(is_blocked_ip_literal( + &reqwest::Url::parse("http://10.0.0.1:80/").unwrap() + )); + assert!(is_blocked_ip_literal( + &reqwest::Url::parse("http://[fc00::1]:80/").unwrap() + )); + assert!(!is_blocked_ip_literal( + &reqwest::Url::parse("http://8.8.8.8:80/").unwrap() + )); + assert!(!is_blocked_ip_literal( + &reqwest::Url::parse("http://example.com:80/").unwrap() + )); + } + #[test] fn blocks_rfc6890_ipv4_range_endpoints() { // RFC 6890 §2.2.2 tables 1-18, checked at each range's low/high endpoints. diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index e4f67b5d5c1..72ad2665ed7 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -79,7 +79,7 @@ pub fn ensure_binaries_built() -> PathBuf { \n\ Or build manually:\n\ \n\ - cargo build -p spacetimedb-cli -p spacetimedb-standalone\n\ + cargo build -p spacetimedb-cli -p spacetimedb-standalone --features spacetimedb-standalone/allow_loopback_http_for_tests\n\ ========================================================================\n", cli_path.display() ); diff --git a/crates/smoketests/DEVELOP.md b/crates/smoketests/DEVELOP.md index 4888fa05829..3eaad776d6e 100644 --- a/crates/smoketests/DEVELOP.md +++ b/crates/smoketests/DEVELOP.md @@ -30,7 +30,7 @@ you MUST rebuild before running tests: cargo smoketest # Option 2: Manually rebuild, then run tests directly -cargo build -p spacetimedb-cli -p spacetimedb-standalone +cargo build -p spacetimedb-cli -p spacetimedb-standalone --features spacetimedb-standalone/allow_loopback_http_for_tests cargo nextest run -p spacetimedb-smoketests ``` @@ -54,7 +54,7 @@ Pre-building avoids this entirely. Standard `cargo test` also works, but you must rebuild first: ```bash -cargo build -p spacetimedb-cli -p spacetimedb-standalone +cargo build -p spacetimedb-cli -p spacetimedb-standalone --features spacetimedb-standalone/allow_loopback_http_for_tests cargo test -p spacetimedb-smoketests ``` diff --git a/crates/smoketests/tests/http_egress.rs b/crates/smoketests/tests/http_egress.rs index f43063b6ef2..f199f08d5de 100644 --- a/crates/smoketests/tests/http_egress.rs +++ b/crates/smoketests/tests/http_egress.rs @@ -1,29 +1,56 @@ -use spacetimedb_smoketests::{require_local_server, Smoketest}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread::JoinHandle; -const MODULE_CODE_HTTP_DISALLOWED_IP: &str = r#" +use spacetimedb_smoketests::Smoketest; + +fn module_code_http_disallowed_ip(addr: &str, port: u16) -> String { + format!( + r#" use spacetimedb::ProcedureContext; #[spacetimedb::procedure] -pub fn request_disallowed_ip(ctx: &mut ProcedureContext) -> Result<(), String> { - match ctx.http.get("http://127.0.0.1:80/") { +pub fn request_redirect_to_disallowed_ip(ctx: &mut ProcedureContext) -> Result<(), String> {{ + match ctx.http.get("http://{addr}:{port}/") {{ Ok(_) => Err("request unexpectedly succeeded".to_owned()), - Err(err) => { + Err(err) => {{ let message = err.to_string(); - if message.contains("refusing to connect to private or special-purpose addresses") { + if message.contains("refusing to connect to private or special-purpose addresses") {{ Ok(()) - } else { - Err(format!("unexpected error from http request: {message}")) - } - } - } + }} else {{ + Err(format!("unexpected error from http request: {{message}}")) + }} + }} + }} +}} +"# + ) +} + +fn spawn_redirect_server(location: &str) -> (u16, JoinHandle>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("failed to bind test redirect server"); + let port = listener + .local_addr() + .expect("failed to read test redirect server address") + .port(); + let location = location.to_owned(); + let handle = std::thread::spawn(move || -> std::io::Result<()> { + let (mut stream, _) = listener.accept()?; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf)?; + let response = + format!("HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + stream.write_all(response.as_bytes())?; + stream.flush()?; + Ok(()) + }); + (port, handle) } -"#; #[test] fn test_http_disallowed_ip_is_blocked() { - require_local_server!(); - - let test = Smoketest::builder().module_code(MODULE_CODE_HTTP_DISALLOWED_IP).build(); + let module_code = module_code_http_disallowed_ip("10.0.0.1", 80); + let test = Smoketest::builder().module_code(&module_code).build(); let output = test.call_output("request_disallowed_ip", &[]); let stdout = String::from_utf8_lossy(&output.stdout); @@ -35,3 +62,25 @@ fn test_http_disallowed_ip_is_blocked() { stderr ); } + +#[test] +fn test_http_redirect_to_disallowed_ip_is_blocked() { + let (port, redirect_server) = spawn_redirect_server("http://10.0.0.1:80/"); + let module_code = module_code_http_disallowed_ip("localhost", port); + let test = Smoketest::builder().module_code(&module_code).build(); + + let output = test.call_output("request_redirect_to_disallowed_ip", &[]); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "Expected request_redirect_to_disallowed_ip to succeed after observing blocked egress error.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr + ); + + redirect_server + .join() + .expect("redirect test server thread panicked") + .expect("redirect test server failed"); +} diff --git a/tools/ci/src/smoketest.rs b/tools/ci/src/smoketest.rs index 595f702a971..60deac8441d 100644 --- a/tools/ci/src/smoketest.rs +++ b/tools/ci/src/smoketest.rs @@ -59,6 +59,8 @@ fn build_binaries() -> Result<()> { "spacetimedb-cli", "-p", "spacetimedb-standalone", + "--features", + "spacetimedb-standalone/allow_loopback_http_for_tests", ]); // Remove cargo/rust env vars that could cause fingerprint mismatches From 8bca5e92b726115de2bf558692973069d7ec0542 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Feb 2026 16:24:33 -0800 Subject: [PATCH 9/9] fix smoketests --- crates/smoketests/tests/http_egress.rs | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/smoketests/tests/http_egress.rs b/crates/smoketests/tests/http_egress.rs index f199f08d5de..35c34dc4579 100644 --- a/crates/smoketests/tests/http_egress.rs +++ b/crates/smoketests/tests/http_egress.rs @@ -1,6 +1,7 @@ use std::io::{Read, Write}; use std::net::TcpListener; use std::thread::JoinHandle; +use std::time::{Duration, Instant}; use spacetimedb_smoketests::Smoketest; @@ -10,7 +11,7 @@ fn module_code_http_disallowed_ip(addr: &str, port: u16) -> String { use spacetimedb::ProcedureContext; #[spacetimedb::procedure] -pub fn request_redirect_to_disallowed_ip(ctx: &mut ProcedureContext) -> Result<(), String> {{ +pub fn request_disallowed_ip(ctx: &mut ProcedureContext) -> Result<(), String> {{ match ctx.http.get("http://{addr}:{port}/") {{ Ok(_) => Err("request unexpectedly succeeded".to_owned()), Err(err) => {{ @@ -29,13 +30,31 @@ pub fn request_redirect_to_disallowed_ip(ctx: &mut ProcedureContext) -> Result<( fn spawn_redirect_server(location: &str) -> (u16, JoinHandle>) { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("failed to bind test redirect server"); + listener + .set_nonblocking(true) + .expect("failed to set test redirect server nonblocking mode"); let port = listener .local_addr() .expect("failed to read test redirect server address") .port(); let location = location.to_owned(); let handle = std::thread::spawn(move || -> std::io::Result<()> { - let (mut stream, _) = listener.accept()?; + let deadline = Instant::now() + Duration::from_secs(10); + let (mut stream, _) = loop { + match listener.accept() { + Ok(pair) => break pair, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "redirect test server did not receive a request; rebuild standalone with allow_loopback_http_for_tests", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(err) => return Err(err), + } + }; let mut buf = [0u8; 1024]; let _ = stream.read(&mut buf)?; let response = @@ -69,12 +88,12 @@ fn test_http_redirect_to_disallowed_ip_is_blocked() { let module_code = module_code_http_disallowed_ip("localhost", port); let test = Smoketest::builder().module_code(&module_code).build(); - let output = test.call_output("request_redirect_to_disallowed_ip", &[]); + let output = test.call_output("request_disallowed_ip", &[]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), - "Expected request_redirect_to_disallowed_ip to succeed after observing blocked egress error.\nstdout:\n{}\nstderr:\n{}", + "Expected request_disallowed_ip to succeed after observing blocked egress error.\nstdout:\n{}\nstderr:\n{}", stdout, stderr );