From dac1c2ccc7d9b5a81a37239c6606ce203e98b73b Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 9 Jul 2026 11:51:45 -0400 Subject: [PATCH 1/2] Restrict test HTTP egress to loopback --- crates/core/src/host/instance_env.rs | 54 ++++++++++++++++------------ 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 982ab6dcbff..37464755c41 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -1059,7 +1059,10 @@ 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 restrict_to_loopback = cfg!(feature = "allow_loopback_http_for_tests"); + if restrict_to_loopback && a != 127 { + return true; + } // 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). @@ -1067,7 +1070,7 @@ fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { // 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; + let is_loopback = !restrict_to_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). @@ -1119,9 +1122,12 @@ 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 restrict_to_loopback = cfg!(feature = "allow_loopback_http_for_tests"); + if restrict_to_loopback && ip != Ipv6Addr::LOCALHOST { + return true; + } // RFC 6890 Section 2.2.3, Table 17: "Loopback Address" (::1/128). - let is_loopback_address = block_loopback && ip == Ipv6Addr::LOCALHOST; + let is_loopback_address = !restrict_to_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). @@ -1491,7 +1497,7 @@ mod test { #[test] 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 allow_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), @@ -1500,7 +1506,7 @@ mod test { // 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), + (Ipv4Addr::new(127, 0, 0, 1), !allow_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). @@ -1542,14 +1548,15 @@ mod test { #[test] fn blocks_ip_literal_hosts_in_urls() { - let block_loopback = !cfg!(feature = "allow_loopback_http_for_tests"); + let restrict_to_loopback = cfg!(feature = "allow_loopback_http_for_tests"); + let allow_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 + !allow_loopback ); assert_eq!( is_blocked_ip_literal(&reqwest::Url::parse("http://[::1]:80/").unwrap()), - block_loopback + !allow_loopback ); assert!(is_blocked_ip_literal( &reqwest::Url::parse("http://10.0.0.1:80/").unwrap() @@ -1557,9 +1564,10 @@ mod test { 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_eq!( + is_blocked_ip_literal(&reqwest::Url::parse("http://8.8.8.8:80/").unwrap()), + restrict_to_loopback + ); assert!(!is_blocked_ip_literal( &reqwest::Url::parse("http://example.com:80/").unwrap() )); @@ -1568,7 +1576,7 @@ mod test { #[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 allow_loopback = cfg!(feature = "allow_loopback_http_for_tests"); let ranges = [ // Table 1: This host on this network (0.0.0.0/8). ( @@ -1596,7 +1604,7 @@ mod test { "loopback", Ipv4Addr::new(127, 0, 0, 0), Ipv4Addr::new(127, 255, 255, 255), - block_loopback, + !allow_loopback, ), // Table 5: Link Local (169.254.0.0/16). ( @@ -1715,10 +1723,11 @@ 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 restrict_to_loopback = cfg!(feature = "allow_loopback_http_for_tests"); + let allow_loopback = cfg!(feature = "allow_loopback_http_for_tests"); let cases = [ // Table 17: Loopback Address (::1/128). - (Ipv6Addr::LOCALHOST, block_loopback), + (Ipv6Addr::LOCALHOST, !allow_loopback), // Table 18: Unspecified Address (::/128). (Ipv6Addr::UNSPECIFIED, true), // Table 19: IPv4-IPv6 Translat. (64:ff9b::/96). @@ -1752,23 +1761,24 @@ mod test { "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 - )))); + // A normal global IPv6 address should only remain allowed in production builds. + assert_eq!( + is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111))), + restrict_to_loopback + ); } #[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 allow_loopback = cfg!(feature = "allow_loopback_http_for_tests"); let ranges = [ // Table 17: Loopback Address (::1/128). ( "loopback-address", Ipv6Addr::LOCALHOST, Ipv6Addr::LOCALHOST, - block_loopback, + !allow_loopback, ), // Table 18: Unspecified Address (::/128). ( From ec22c93f4d16c4345a71a6c4218ee61b13b360f6 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Fri, 10 Jul 2026 12:20:26 -0400 Subject: [PATCH 2/2] Add HTTP egress policy option --- crates/core/src/config.rs | 40 ++++++++ crates/core/src/host/host_controller.rs | 19 +++- crates/core/src/host/instance_env.rs | 94 ++++++++++++++----- crates/core/src/host/module_common.rs | 16 +++- crates/core/src/host/v8/mod.rs | 8 +- .../src/host/wasm_common/module_host_actor.rs | 8 +- crates/core/src/module_host_context.rs | 2 + crates/standalone/src/lib.rs | 6 +- crates/standalone/src/subcommands/start.rs | 17 +++- crates/testing/src/modules.rs | 1 + 10 files changed, 171 insertions(+), 40 deletions(-) diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 7821f61f5d9..7e9ebe4a2f2 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -1,5 +1,6 @@ use std::num::NonZeroUsize; use std::path::Path; +use std::str::FromStr; use std::time::Duration; use std::{fmt, io}; @@ -139,6 +140,7 @@ pub struct ConfigFile { pub logs: LogConfig, pub wasm: WasmConfig, pub v8: V8Config, + pub http_egress_policy: HttpEgressPolicy, } #[derive(serde::Deserialize, Default)] @@ -154,6 +156,8 @@ struct ConfigFileToml { v8: V8ConfigToml, #[serde(default)] v8_heap_policy: V8HeapPolicyConfig, + #[serde(default)] + http_egress_policy: HttpEgressPolicy, } impl<'de> serde::Deserialize<'de> for ConfigFile { @@ -172,6 +176,7 @@ impl<'de> serde::Deserialize<'de> for ConfigFile { procedure_instance_pool_size: config.v8.procedure_instance_pool_size, heap_policy: config.v8_heap_policy, }, + http_egress_policy: config.http_egress_policy, }) } } @@ -202,6 +207,37 @@ impl CertificateAuthority { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HttpEgressPolicy { + PublicInternet, + AllowLoopback, + LoopbackOnly, +} + +impl Default for HttpEgressPolicy { + fn default() -> Self { + if cfg!(feature = "allow_loopback_http_for_tests") { + Self::AllowLoopback + } else { + Self::PublicInternet + } + } +} + +impl FromStr for HttpEgressPolicy { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "public-internet" => Ok(Self::PublicInternet), + "allow-loopback" => Ok(Self::AllowLoopback), + "loopback-only" => Ok(Self::LoopbackOnly), + _ => Err(format!("invalid HTTP egress policy `{value}`")), + } + } +} + #[serde_with::serde_as] #[derive(Clone, serde::Deserialize, Default)] #[serde(rename_all = "kebab-case")] @@ -562,6 +598,7 @@ mod tests { fn v8_heap_policy_defaults_when_omitted() { let config: ConfigFile = toml::from_str("").unwrap(); + assert_eq!(config.http_egress_policy, HttpEgressPolicy::default()); assert_eq!( config.wasm.procedure_instance_pool_size, default_wasm_procedure_instance_pool_size() @@ -583,6 +620,8 @@ mod tests { #[test] fn v8_heap_policy_parses_from_toml() { let toml = r#" + http-egress-policy = "loopback-only" + [wasm] procedure-instance-pool-size = 4 @@ -599,6 +638,7 @@ mod tests { let config: ConfigFile = toml::from_str(toml).unwrap(); + assert_eq!(config.http_egress_policy, HttpEgressPolicy::LoopbackOnly); assert_eq!(config.wasm.procedure_instance_pool_size.get(), 4); assert_eq!(config.v8.procedure_instance_pool_size.get(), 3); assert_eq!(config.v8.heap_policy.heap_check_request_interval, None); diff --git a/crates/core/src/host/host_controller.rs b/crates/core/src/host/host_controller.rs index 2f9c232bb0b..842cb5f52c8 100644 --- a/crates/core/src/host/host_controller.rs +++ b/crates/core/src/host/host_controller.rs @@ -4,7 +4,7 @@ use super::v8::V8HeapMetrics; use super::wasmtime::{WasmMemoryBytesMetric, WasmtimeRuntime}; use super::{Scheduler, UpdateDatabaseResult}; use crate::client::{ClientActorId, ClientName}; -use crate::config::{V8Config, WasmConfig}; +use crate::config::{HttpEgressPolicy, V8Config, WasmConfig}; use crate::database_logger::DatabaseLogger; use crate::db::persistence::PersistenceProvider; use crate::db::relational_db::{self, spawn_view_cleanup_loop, DiskSizeFn, RelationalDB, Txdata}; @@ -203,17 +203,23 @@ pub struct HostController { pub(crate) struct HostRuntimes { wasmtime: WasmtimeRuntime, v8: V8Runtime, + http_egress_policy: HttpEgressPolicy, } #[derive(Clone, Copy, Debug, Default)] pub struct HostRuntimeConfig { pub wasm: WasmConfig, pub v8: V8Config, + pub http_egress_policy: HttpEgressPolicy, } impl HostRuntimeConfig { - pub fn new(wasm: WasmConfig, v8: V8Config) -> Self { - Self { wasm, v8 } + pub fn new(wasm: WasmConfig, v8: V8Config, http_egress_policy: HttpEgressPolicy) -> Self { + Self { + wasm, + v8, + http_egress_policy, + } } } @@ -221,7 +227,11 @@ impl HostRuntimes { fn new(data_dir: Option<&ServerDataDir>, config: HostRuntimeConfig) -> Arc { let wasmtime = WasmtimeRuntime::new(data_dir, config.wasm); let v8 = V8Runtime::new(config.v8); - Arc::new(Self { wasmtime, v8 }) + Arc::new(Self { + wasmtime, + v8, + http_egress_policy: config.http_egress_policy, + }) } } @@ -838,6 +848,7 @@ async fn make_module_host( scheduler, program_hash: program.hash, energy_monitor, + http_egress_policy: runtimes.http_egress_policy, }; match HostType::from(program.kind) { diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 37464755c41..d9994c973a0 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -1,4 +1,5 @@ use super::scheduler::{get_schedule_from_row, ScheduleError, Scheduler}; +use crate::config::HttpEgressPolicy; use crate::database_logger::{BacktraceFrame, BacktraceProvider, LogLevel, ModuleBacktrace, Record}; use crate::db::relational_db::{MutTx, RelationalDB}; use crate::error::{DBError, DatastoreError, IndexError, NodesError}; @@ -53,6 +54,7 @@ pub struct InstanceEnv { in_anon_tx: bool, /// A procedure's last known transaction offset. procedure_last_tx_offset: Option, + http_egress_policy: HttpEgressPolicy, } /// `InstanceEnv` needs to be `Send` because it is created on the host thread @@ -224,7 +226,7 @@ impl ChunkedWriter { // Generic 'instance environment' delegated to from various host types. impl InstanceEnv { - pub fn new(replica_ctx: Arc, scheduler: Scheduler) -> Self { + pub fn new(replica_ctx: Arc, scheduler: Scheduler, http_egress_policy: HttpEgressPolicy) -> Self { Self { replica_ctx, scheduler, @@ -237,6 +239,7 @@ impl InstanceEnv { func_name: None, in_anon_tx: false, procedure_last_tx_offset: None, + http_egress_policy, } } @@ -926,13 +929,15 @@ impl InstanceEnv { let reqwest = reqwest; + let http_egress_policy = self.http_egress_policy; + // Check if we have a blocked IP address, since IP literals bypass DNS resolution. - if is_blocked_ip_literal(reqwest.url()) { + if is_blocked_ip_literal(reqwest.url(), http_egress_policy) { return Err(NodesError::HttpError(BLOCKED_HTTP_ADDRESS_ERROR.to_string())); } - let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { - if is_blocked_ip_literal(attempt.url()) { + let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { + if is_blocked_ip_literal(attempt.url(), http_egress_policy) { attempt.error(BLOCKED_HTTP_ADDRESS_ERROR) } else { reqwest::redirect::Policy::default().redirect(attempt) @@ -944,7 +949,7 @@ 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::builder() - .dns_resolver(Arc::new(FilteredDnsResolver)) + .dns_resolver(Arc::new(FilteredDnsResolver { http_egress_policy })) .redirect(redirect_policy) .build() .map_err(http_error)? @@ -1022,14 +1027,19 @@ const HTTP_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const HTTP_MAX_TIMEOUT: Duration = Duration::from_secs(180); const BLOCKED_HTTP_ADDRESS_ERROR: &str = "refusing to connect to private or special-purpose addresses"; -struct FilteredDnsResolver; +struct FilteredDnsResolver { + http_egress_policy: HttpEgressPolicy, +} impl reqwest::dns::Resolve for FilteredDnsResolver { fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { let host = name.as_str().to_owned(); + let http_egress_policy = self.http_egress_policy; 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(); + let filtered_addrs: Vec = addrs + .filter(|addr| !is_blocked_ip(addr.ip(), http_egress_policy)) + .collect(); if filtered_addrs.is_empty() { return Err( @@ -1042,25 +1052,24 @@ impl reqwest::dns::Resolve for FilteredDnsResolver { } } -fn is_blocked_ip_literal(url: &reqwest::Url) -> bool { +fn is_blocked_ip_literal(url: &reqwest::Url, http_egress_policy: HttpEgressPolicy) -> 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::Ipv4(ip)) => is_blocked_ip(IpAddr::V4(ip), http_egress_policy), + Some(url::Host::Ipv6(ip)) => is_blocked_ip(IpAddr::V6(ip), http_egress_policy), Some(url::Host::Domain(_)) | None => false, } } -fn is_blocked_ip(ip: IpAddr) -> bool { +fn is_blocked_ip(ip: IpAddr, http_egress_policy: HttpEgressPolicy) -> bool { match ip { - IpAddr::V4(ip) => is_blocked_ipv4(ip), - IpAddr::V6(ip) => is_blocked_ipv6(ip), + IpAddr::V4(ip) => is_blocked_ipv4(ip, http_egress_policy), + IpAddr::V6(ip) => is_blocked_ipv6(ip, http_egress_policy), } } -fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { +fn is_blocked_ipv4(ip: Ipv4Addr, http_egress_policy: HttpEgressPolicy) -> bool { let [a, b, c, d] = ip.octets(); - let restrict_to_loopback = cfg!(feature = "allow_loopback_http_for_tests"); - if restrict_to_loopback && a != 127 { + if http_egress_policy == HttpEgressPolicy::LoopbackOnly && a != 127 { return true; } // RFC 6890 Section 2.2.2, Table 1: "This host on this network" (0.0.0.0/8). @@ -1070,7 +1079,7 @@ fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { // 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 = !restrict_to_loopback && a == 127; + let is_loopback = http_egress_policy == HttpEgressPolicy::PublicInternet && 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). @@ -1120,14 +1129,13 @@ fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { || is_limited_broadcast } -fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { +fn is_blocked_ipv6(ip: Ipv6Addr, http_egress_policy: HttpEgressPolicy) -> bool { let segments = ip.segments(); - let restrict_to_loopback = cfg!(feature = "allow_loopback_http_for_tests"); - if restrict_to_loopback && ip != Ipv6Addr::LOCALHOST { + if http_egress_policy == HttpEgressPolicy::LoopbackOnly && ip != Ipv6Addr::LOCALHOST { return true; } // RFC 6890 Section 2.2.3, Table 17: "Loopback Address" (::1/128). - let is_loopback_address = !restrict_to_loopback && ip == Ipv6Addr::LOCALHOST; + let is_loopback_address = http_egress_policy == HttpEgressPolicy::PublicInternet && 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). @@ -1362,6 +1370,14 @@ mod test { use spacetimedb_primitives::{IndexId, TableId}; use spacetimedb_sats::product; + fn is_blocked_ip(ip: IpAddr) -> bool { + super::is_blocked_ip(ip, HttpEgressPolicy::default()) + } + + fn is_blocked_ip_literal(url: &reqwest::Url) -> bool { + super::is_blocked_ip_literal(url, HttpEgressPolicy::default()) + } + /// An `InstanceEnv` requires a `DatabaseLogger` fn temp_logger() -> DatabaseLogger { DatabaseLogger::in_memory(64 * 1024) @@ -1398,7 +1414,10 @@ mod test { fn instance_env(db: Arc) -> Result<(InstanceEnv, tokio::runtime::Runtime)> { let (scheduler, _) = Scheduler::open(db.clone()); let (replica_context, runtime) = replica_ctx(db)?; - Ok((InstanceEnv::new(Arc::new(replica_context), scheduler), runtime)) + Ok(( + InstanceEnv::new(Arc::new(replica_context), scheduler, HttpEgressPolicy::default()), + runtime, + )) } /// An in-memory `RelationalDB` for testing. @@ -1548,7 +1567,6 @@ mod test { #[test] fn blocks_ip_literal_hosts_in_urls() { - let restrict_to_loopback = cfg!(feature = "allow_loopback_http_for_tests"); let allow_loopback = cfg!(feature = "allow_loopback_http_for_tests"); assert_eq!( is_blocked_ip_literal(&reqwest::Url::parse("http://127.0.0.1:80/").unwrap()), @@ -1566,13 +1584,38 @@ mod test { )); assert_eq!( is_blocked_ip_literal(&reqwest::Url::parse("http://8.8.8.8:80/").unwrap()), - restrict_to_loopback + false ); assert!(!is_blocked_ip_literal( &reqwest::Url::parse("http://example.com:80/").unwrap() )); } + #[test] + fn loopback_only_policy_blocks_non_loopback_ip_literals() { + let policy = HttpEgressPolicy::LoopbackOnly; + assert!(!super::is_blocked_ip_literal( + &reqwest::Url::parse("http://127.0.0.1:80/").unwrap(), + policy, + )); + assert!(!super::is_blocked_ip_literal( + &reqwest::Url::parse("http://[::1]:80/").unwrap(), + policy, + )); + assert!(super::is_blocked_ip_literal( + &reqwest::Url::parse("http://8.8.8.8:80/").unwrap(), + policy, + )); + assert!(super::is_blocked_ip_literal( + &reqwest::Url::parse("http://10.0.0.1:80/").unwrap(), + policy, + )); + assert!(!super::is_blocked_ip_literal( + &reqwest::Url::parse("http://example.com:80/").unwrap(), + policy, + )); + } + #[test] fn blocks_rfc6890_ipv4_range_endpoints() { // RFC 6890 §2.2.2 tables 1-18, checked at each range's low/high endpoints. @@ -1723,7 +1766,6 @@ mod test { #[test] fn blocks_each_rfc6890_ipv6_range() { // RFC 6890 §2.2.3 tables 17-29. - let restrict_to_loopback = cfg!(feature = "allow_loopback_http_for_tests"); let allow_loopback = cfg!(feature = "allow_loopback_http_for_tests"); let cases = [ // Table 17: Loopback Address (::1/128). @@ -1764,7 +1806,7 @@ mod test { // A normal global IPv6 address should only remain allowed in production builds. assert_eq!( is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111))), - restrict_to_loopback + false ); } diff --git a/crates/core/src/host/module_common.rs b/crates/core/src/host/module_common.rs index 49f1126b54b..0997025b90d 100644 --- a/crates/core/src/host/module_common.rs +++ b/crates/core/src/host/module_common.rs @@ -2,6 +2,7 @@ //! like WASM and V8. use crate::{ + config::HttpEgressPolicy, energy::EnergyMonitor, host::{ module_host::ModuleInfo, @@ -37,7 +38,13 @@ pub fn build_common_module_from_raw( replica_ctx.subscriptions.clone(), ); - Ok(ModuleCommon::new(replica_ctx, mcc.scheduler, info, mcc.energy_monitor)) + Ok(ModuleCommon::new( + replica_ctx, + mcc.scheduler, + info, + mcc.energy_monitor, + mcc.http_egress_policy, + )) } /// Non-runtime-specific parts of a module. @@ -47,6 +54,7 @@ pub(crate) struct ModuleCommon { scheduler: Scheduler, info: Arc, energy_monitor: Arc, + http_egress_policy: HttpEgressPolicy, } impl ModuleCommon { @@ -56,12 +64,14 @@ impl ModuleCommon { scheduler: Scheduler, info: Arc, energy_monitor: Arc, + http_egress_policy: HttpEgressPolicy, ) -> Self { Self { replica_context, scheduler, info, energy_monitor, + http_egress_policy, } } @@ -74,6 +84,10 @@ impl ModuleCommon { pub fn energy_monitor(&self) -> Arc { self.energy_monitor.clone() } + + pub fn http_egress_policy(&self) -> HttpEgressPolicy { + self.http_egress_policy + } } impl ModuleCommon { diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 52abf373e48..2f815acf5d2 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -1619,11 +1619,11 @@ where scope_with_context!(let scope, &mut isolate, Context::new(scope, Default::default())); // Setup the instance environment. - let (replica_ctx, scheduler) = match &generation_module_or_mcc { - Either::Left(module) => (module.replica_ctx(), module.scheduler()), - Either::Right(mcc) => (&mcc.replica_ctx, &mcc.scheduler), + let (replica_ctx, scheduler, http_egress_policy) = match &generation_module_or_mcc { + Either::Left(module) => (module.replica_ctx(), module.scheduler(), module.http_egress_policy()), + Either::Right(mcc) => (&mcc.replica_ctx, &mcc.scheduler, mcc.http_egress_policy), }; - let instance_env = InstanceEnv::new(replica_ctx.clone(), scheduler.clone()); + let instance_env = InstanceEnv::new(replica_ctx.clone(), scheduler.clone(), http_egress_policy); scope.set_slot(JsInstanceEnv::new(instance_env)); let startup_result = panic::catch_unwind(AssertUnwindSafe(|| { diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index 97951829d16..3fdfb5fa40e 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -397,7 +397,7 @@ impl WasmModuleHostActor { func_names }; let uninit_instance = module.instantiate_pre()?; - let instance_env = InstanceEnv::new(mcc.replica_ctx.clone(), mcc.scheduler.clone()); + let instance_env = InstanceEnv::new(mcc.replica_ctx.clone(), mcc.scheduler.clone(), mcc.http_egress_policy); let mut instance = uninit_instance.instantiate(instance_env, &func_names)?; let desc = instance.extract_descriptions()?; @@ -453,7 +453,11 @@ impl WasmModuleHostActor { pub fn create_instance(&self) -> WasmModuleInstance { let common = &self.common; - let env = InstanceEnv::new(common.replica_ctx().clone(), common.scheduler().clone()); + let env = InstanceEnv::new( + common.replica_ctx().clone(), + common.scheduler().clone(), + common.http_egress_policy(), + ); // this shouldn't fail, since we already called module.create_instance() // before and it didn't error, and ideally they should be deterministic let mut instance = self diff --git a/crates/core/src/module_host_context.rs b/crates/core/src/module_host_context.rs index 50f8c258a37..362a774443b 100644 --- a/crates/core/src/module_host_context.rs +++ b/crates/core/src/module_host_context.rs @@ -1,3 +1,4 @@ +use crate::config::HttpEgressPolicy; use crate::energy::EnergyMonitor; use crate::host::scheduler::Scheduler; use crate::replica_context::ReplicaContext; @@ -9,4 +10,5 @@ pub struct ModuleCreationContext { pub scheduler: Scheduler, pub program_hash: Hash, pub energy_monitor: Arc, + pub http_egress_policy: HttpEgressPolicy, } diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index f806f18e995..1acd08513ca 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -10,7 +10,7 @@ use async_trait::async_trait; use clap::{ArgMatches, Command}; use http::StatusCode; use spacetimedb::client::ClientActorIndex; -use spacetimedb::config::{CertificateAuthority, MetadataFile, V8Config, WasmConfig}; +use spacetimedb::config::{CertificateAuthority, HttpEgressPolicy, MetadataFile, V8Config, WasmConfig}; use spacetimedb::db; use spacetimedb::db::persistence::{DurabilityConfig, LocalPersistenceProvider}; use spacetimedb::energy::{EnergyBalance, EnergyQuanta, NullEnergyMonitor}; @@ -46,6 +46,7 @@ pub struct StandaloneOptions { pub websocket: WebSocketOptions, pub wasm: WasmConfig, pub v8: V8Config, + pub http_egress_policy: HttpEgressPolicy, } pub struct StandaloneEnv { @@ -83,7 +84,7 @@ impl StandaloneEnv { let host_controller = HostController::new( data_dir, config.db_config, - HostRuntimeConfig::new(config.wasm, config.v8), + HostRuntimeConfig::new(config.wasm, config.v8, config.http_egress_policy), program_store.clone(), energy_monitor, Arc::new(()), @@ -676,6 +677,7 @@ mod tests { websocket: WebSocketOptions::default(), wasm: WasmConfig::default(), v8: V8Config::default(), + http_egress_policy: HttpEgressPolicy::default(), }; let _env = StandaloneEnv::init(config, &ca, data_dir.clone(), JobCores::without_pinned_cores()).await?; diff --git a/crates/standalone/src/subcommands/start.rs b/crates/standalone/src/subcommands/start.rs index 01078f7af8d..a17312071d4 100644 --- a/crates/standalone/src/subcommands/start.rs +++ b/crates/standalone/src/subcommands/start.rs @@ -10,7 +10,7 @@ use anyhow::Context; use axum::extract::DefaultBodyLimit; use clap::ArgAction::SetTrue; use clap::{Arg, ArgMatches}; -use spacetimedb::config::{parse_config, CertificateAuthority}; +use spacetimedb::config::{parse_config, CertificateAuthority, HttpEgressPolicy}; use spacetimedb::db::persistence::{CommitlogConfig, DurabilityConfig}; use spacetimedb::db::{self, Storage}; use spacetimedb::startup::{self, TracingOptions}; @@ -92,6 +92,12 @@ pub fn cli() -> clap::Command { .action(SetTrue) .help("Run in non-interactive mode (fail immediately if port is in use)"), ) + .arg( + Arg::new("http_egress_policy") + .long("http-egress-policy") + .value_parser(["public-internet", "allow-loopback", "loopback-only"]) + .help("Controls which addresses module HTTP requests may reach"), + ) // .after_help("Run `spacetime help start` for more detailed information.") } @@ -115,6 +121,11 @@ pub async fn exec(args: &ArgMatches, db_cores: JobCores) -> anyhow::Result<()> { let listen_addr = args.get_one::("listen_addr").unwrap(); let pg_port = args.get_one::("pg_port"); let non_interactive = args.get_flag("non_interactive"); + let http_egress_policy = args + .get_one::("http_egress_policy") + .map(|value| value.parse::()) + .transpose() + .map_err(anyhow::Error::msg)?; let cert_dir = args.get_one::("jwt_key_dir"); let certs = Option::zip( args.get_one::("jwt_pub_key_path").cloned(), @@ -190,6 +201,7 @@ pub async fn exec(args: &ArgMatches, db_cores: JobCores) -> anyhow::Result<()> { websocket: config.websocket, wasm: config.common.wasm, v8: config.common.v8, + http_egress_policy: http_egress_policy.unwrap_or(config.common.http_egress_policy), }, &certs, data_dir, @@ -510,6 +522,8 @@ mod tests { #[test] fn options_from_partial_toml() { let toml = r#" + http-egress-policy = "loopback-only" + [logs] directives = [ "banana_shake=strawberry", @@ -547,6 +561,7 @@ mod tests { // so check `common` in a pedestrian way. assert_eq!(&config.common.logs.directives, &["banana_shake=strawberry"]); assert!(config.common.certificate_authority.is_none()); + assert_eq!(config.common.http_egress_policy, HttpEgressPolicy::LoopbackOnly); assert_eq!(config.common.wasm.procedure_instance_pool_size.get(), 4); assert_eq!(config.common.v8.procedure_instance_pool_size.get(), 3); assert_eq!(config.common.v8.heap_policy.heap_check_request_interval, None); diff --git a/crates/testing/src/modules.rs b/crates/testing/src/modules.rs index b03b87df9b5..4173190ad0e 100644 --- a/crates/testing/src/modules.rs +++ b/crates/testing/src/modules.rs @@ -277,6 +277,7 @@ impl CompiledModule { websocket: WebSocketOptions::default(), wasm: Default::default(), v8: Default::default(), + http_egress_policy: Default::default(), }, &certs, paths.data_dir.into(),