Skip to content

Commit 960490d

Browse files
authored
Merge pull request #3150 from spinframework/block-networks
Add runtime config for blocking outbound connections by CIDR
2 parents 40e88fa + b8ef428 commit 960490d

File tree

22 files changed

+840
-466
lines changed

22 files changed

+840
-466
lines changed

Cargo.lock

Lines changed: 28 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,7 @@ rusqlite = "0.34"
150150
# In `rustls` turn off the `aws_lc_rs` default feature and turn on `ring`.
151151
# If both `aws_lc_rs` and `ring` are enabled, a panic at runtime will occur.
152152
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] }
153-
rustls-pemfile = "2.2"
154-
rustls-pki-types = "1.8"
153+
rustls-pki-types = "1.12"
155154
semver = "1"
156155
serde = { version = "1", features = ["derive", "rc"] }
157156
serde_json = "1.0"
@@ -168,9 +167,6 @@ toml_edit = "0.22"
168167
tracing = { version = "0.1", features = ["log"] }
169168
url = "2"
170169
walkdir = "2"
171-
wasi-common-preview1 = { version = "33.0.0", package = "wasi-common", features = [
172-
"tokio",
173-
] }
174170
wasm-encoder = "0.230"
175171
wasm-metadata = "0.230"
176172
wasm-pkg-client = "0.10"

crates/factor-outbound-http/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ anyhow = { workspace = true }
99
http = { workspace = true }
1010
http-body-util = { workspace = true }
1111
hyper = { workspace = true }
12-
ip_network = "0.4"
1312
reqwest = { workspace = true, features = ["gzip"] }
1413
rustls = { workspace = true }
1514
spin-factor-outbound-networking = { path = "../factor-outbound-networking" }

crates/factor-outbound-http/src/lib.rs

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use http::{
1313
};
1414
use intercept::OutboundHttpInterceptor;
1515
use spin_factor_outbound_networking::{
16-
ComponentTlsConfigs, OutboundAllowedHosts, OutboundNetworkingFactor,
16+
BlockedNetworks, ComponentTlsClientConfigs, OutboundAllowedHosts, OutboundNetworkingFactor,
1717
};
1818
use spin_factors::{
1919
anyhow, ConfigureAppContext, Factor, PrepareContext, RuntimeFactors, SelfInstanceBuilder,
@@ -26,25 +26,9 @@ pub use wasmtime_wasi_http::{
2626
HttpResult,
2727
};
2828

29+
#[derive(Default)]
2930
pub struct OutboundHttpFactor {
30-
allow_private_ips: bool,
31-
}
32-
33-
impl OutboundHttpFactor {
34-
/// Create a new OutboundHttpFactor.
35-
///
36-
/// If `allow_private_ips` is true, requests to private IP addresses will be allowed.
37-
pub fn new(allow_private_ips: bool) -> Self {
38-
Self { allow_private_ips }
39-
}
40-
}
41-
42-
impl Default for OutboundHttpFactor {
43-
fn default() -> Self {
44-
Self {
45-
allow_private_ips: true,
46-
}
47-
}
31+
_priv: (),
4832
}
4933

5034
impl Factor for OutboundHttpFactor {
@@ -71,11 +55,12 @@ impl Factor for OutboundHttpFactor {
7155
) -> anyhow::Result<Self::InstanceBuilder> {
7256
let outbound_networking = ctx.instance_builder::<OutboundNetworkingFactor>()?;
7357
let allowed_hosts = outbound_networking.allowed_hosts();
74-
let component_tls_configs = outbound_networking.component_tls_configs().clone();
58+
let blocked_networks = outbound_networking.blocked_networks();
59+
let component_tls_configs = outbound_networking.component_tls_configs();
7560
Ok(InstanceState {
7661
wasi_http_ctx: WasiHttpCtx::new(),
7762
allowed_hosts,
78-
allow_private_ips: self.allow_private_ips,
63+
blocked_networks,
7964
component_tls_configs,
8065
self_request_origin: None,
8166
request_interceptor: None,
@@ -87,8 +72,8 @@ impl Factor for OutboundHttpFactor {
8772
pub struct InstanceState {
8873
wasi_http_ctx: WasiHttpCtx,
8974
allowed_hosts: OutboundAllowedHosts,
90-
allow_private_ips: bool,
91-
component_tls_configs: ComponentTlsConfigs,
75+
blocked_networks: BlockedNetworks,
76+
component_tls_configs: ComponentTlsClientConfigs,
9277
self_request_origin: Option<SelfRequestOrigin>,
9378
request_interceptor: Option<Arc<dyn OutboundHttpInterceptor>>,
9479
// Connection-pooling client for 'fermyon:spin/http' interface

crates/factor-outbound-http/src/wasi.rs

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
use std::{error::Error, net::IpAddr, sync::Arc};
1+
use std::{error::Error, sync::Arc};
22

33
use anyhow::Context;
44
use http::{header::HOST, Request};
55
use http_body_util::BodyExt;
6-
use ip_network::IpNetwork;
7-
use rustls::ClientConfig;
8-
use spin_factor_outbound_networking::{ComponentTlsConfigs, OutboundAllowedHosts};
6+
use spin_factor_outbound_networking::{
7+
BlockedNetworks, ComponentTlsClientConfigs, OutboundAllowedHosts, TlsClientConfig,
8+
};
99
use spin_factors::{wasmtime::component::ResourceTable, RuntimeFactorsInstanceState};
1010
use tokio::{net::TcpStream, time::timeout};
1111
use tracing::{field::Empty, instrument, Instrument};
@@ -97,7 +97,7 @@ impl WasiHttpView for WasiHttpImplInner<'_> {
9797
self.state.component_tls_configs.clone(),
9898
self.state.request_interceptor.clone(),
9999
self.state.self_request_origin.clone(),
100-
self.state.allow_private_ips,
100+
self.state.blocked_networks.clone(),
101101
)
102102
.in_current_span(),
103103
),
@@ -109,10 +109,10 @@ async fn send_request_impl(
109109
mut request: Request<wasmtime_wasi_http::body::HyperOutgoingBody>,
110110
mut config: wasmtime_wasi_http::types::OutgoingRequestConfig,
111111
outbound_allowed_hosts: OutboundAllowedHosts,
112-
component_tls_configs: ComponentTlsConfigs,
112+
component_tls_configs: ComponentTlsClientConfigs,
113113
request_interceptor: Option<Arc<dyn OutboundHttpInterceptor>>,
114114
self_request_origin: Option<SelfRequestOrigin>,
115-
allow_private_ips: bool,
115+
blocked_networks: BlockedNetworks,
116116
) -> anyhow::Result<Result<IncomingResponse, ErrorCode>> {
117117
// wasmtime-wasi-http fills in scheme and authority for relative URLs
118118
// (e.g. https://:443/<path>), which makes them hard to reason about.
@@ -196,7 +196,7 @@ async fn send_request_impl(
196196
span.record("server.port", port.as_u16());
197197
}
198198

199-
Ok(send_request_handler(request, config, tls_client_config, allow_private_ips).await)
199+
Ok(send_request_handler(request, config, tls_client_config, blocked_networks).await)
200200
}
201201

202202
/// This is a fork of wasmtime_wasi_http::default_send_request_handler function
@@ -210,8 +210,8 @@ async fn send_request_handler(
210210
first_byte_timeout,
211211
between_bytes_timeout,
212212
}: wasmtime_wasi_http::types::OutgoingRequestConfig,
213-
tls_client_config: Arc<ClientConfig>,
214-
allow_private_ips: bool,
213+
tls_client_config: TlsClientConfig,
214+
blocked_networks: BlockedNetworks,
215215
) -> Result<wasmtime_wasi_http::types::IncomingResponse, ErrorCode> {
216216
let authority_str = if let Some(authority) = request.uri().authority() {
217217
if authority.port().is_some() {
@@ -230,12 +230,15 @@ async fn send_request_handler(
230230
.map_err(|_| dns_error("address not available".into(), 0))?
231231
.collect::<Vec<_>>();
232232

233-
// Potentially filter out private IPs
234-
if !allow_private_ips && !socket_addrs.is_empty() {
235-
socket_addrs.retain(|addr| !is_private_ip(addr.ip()));
236-
if socket_addrs.is_empty() {
237-
return Err(ErrorCode::DestinationIpProhibited);
238-
}
233+
// Remove blocked IPs
234+
let blocked_addrs = blocked_networks.remove_blocked(&mut socket_addrs);
235+
if socket_addrs.is_empty() && !blocked_addrs.is_empty() {
236+
tracing::error!(
237+
"error.type" = "destination_ip_prohibited",
238+
?blocked_addrs,
239+
"all destination IP(s) prohibited by runtime config"
240+
);
241+
return Err(ErrorCode::DestinationIpProhibited);
239242
}
240243

241244
let tcp_stream = timeout(connect_timeout, TcpStream::connect(socket_addrs.as_slice()))
@@ -257,7 +260,7 @@ async fn send_request_handler(
257260
#[cfg(not(any(target_arch = "riscv64", target_arch = "s390x")))]
258261
{
259262
use rustls::pki_types::ServerName;
260-
let connector = tokio_rustls::TlsConnector::from(tls_client_config);
263+
let connector = tokio_rustls::TlsConnector::from(tls_client_config.inner());
261264
let mut parts = authority_str.split(':');
262265
let host = parts.next().unwrap_or(&authority_str);
263266
let domain = ServerName::try_from(host)
@@ -362,8 +365,3 @@ fn dns_error(rcode: String, info_code: u16) -> ErrorCode {
362365
info_code: Some(info_code),
363366
})
364367
}
365-
366-
/// Returns true if the IP is a private IP address.
367-
fn is_private_ip(ip: IpAddr) -> bool {
368-
!IpNetwork::from(ip).is_global()
369-
}

crates/factor-outbound-http/tests/factor_test.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,23 @@ async fn test_instance_state(
111111
let factors = TestFactors {
112112
variables: VariablesFactor::default(),
113113
networking: OutboundNetworkingFactor::new(),
114-
http: OutboundHttpFactor::new(allow_private_ips),
114+
http: OutboundHttpFactor::default(),
115115
};
116-
let env = TestEnvironment::new(factors).extend_manifest(toml! {
117-
[component.test-component]
118-
source = "does-not-exist.wasm"
119-
allowed_outbound_hosts = [allowed_outbound_hosts]
120-
});
116+
let env = TestEnvironment::new(factors)
117+
.extend_manifest(toml! {
118+
[component.test-component]
119+
source = "does-not-exist.wasm"
120+
allowed_outbound_hosts = [allowed_outbound_hosts]
121+
})
122+
.runtime_config(TestFactorsRuntimeConfig {
123+
networking: Some(
124+
spin_factor_outbound_networking::runtime_config::RuntimeConfig {
125+
block_private_networks: !allow_private_ips,
126+
..Default::default()
127+
},
128+
),
129+
..Default::default()
130+
})?;
121131
env.build_instance_state().await
122132
}
123133

crates/factor-outbound-networking/Cargo.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ edition = { workspace = true }
88
anyhow = { workspace = true }
99
futures-util = { workspace = true }
1010
http = { workspace = true }
11-
ipnet = "2"
11+
ip_network = "0.4.1"
12+
ip_network_table = "0.2.0"
1213
rustls = { workspace = true }
13-
rustls-pemfile = { workspace = true, optional = true }
1414
rustls-pki-types = { workspace = true }
1515
serde = { workspace = true }
1616
spin-expressions = { path = "../expressions" }
@@ -35,8 +35,6 @@ wasmtime-wasi = { workspace = true }
3535
[features]
3636
default = ["spin-cli"]
3737
# Includes the runtime configuration handling used by the Spin CLI
38-
spin-cli = [
39-
"dep:rustls-pemfile",
40-
]
38+
spin-cli = []
4139
[lints]
4240
workspace = true

crates/factor-outbound-networking/src/config.rs renamed to crates/factor-outbound-networking/src/allowed_hosts.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ pub enum HostConfig {
190190
AnySubdomain(String),
191191
ToSelf,
192192
List(Vec<String>),
193-
Cidr(ipnet::IpNet),
193+
Cidr(ip_network::IpNetwork),
194194
}
195195

196196
impl HostConfig {
@@ -209,7 +209,7 @@ impl HostConfig {
209209
bail!("host lists are not yet supported")
210210
}
211211

212-
if let Ok(net) = host.parse::<ipnet::IpNet>() {
212+
if let Ok(net) = ip_network::IpNetwork::from_str_truncate(host) {
213213
return Ok(Self::Cidr(net));
214214
}
215215

@@ -244,7 +244,7 @@ impl HostConfig {
244244
let Ok(ip) = host.parse::<std::net::IpAddr>() else {
245245
return false;
246246
};
247-
c.contains(&ip)
247+
c.contains(ip)
248248
}
249249
}
250250
}
@@ -566,6 +566,8 @@ mod test {
566566
spin_expressions::PreparedResolver::default()
567567
}
568568

569+
use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
570+
569571
use super::*;
570572
use std::net::{Ipv4Addr, Ipv6Addr};
571573

@@ -762,8 +764,8 @@ mod test {
762764
assert_eq!(
763765
AllowedHostConfig::new(
764766
SchemeConfig::Any,
765-
HostConfig::Cidr(ipnet::IpNet::V4(
766-
ipnet::Ipv4Net::new(Ipv4Addr::new(127, 0, 0, 0), 24).unwrap()
767+
HostConfig::Cidr(IpNetwork::V4(
768+
Ipv4Network::new(Ipv4Addr::new(127, 0, 0, 0), 24).unwrap()
767769
)),
768770
PortConfig::new(80)
769771
),
@@ -773,8 +775,8 @@ mod test {
773775
assert_eq!(
774776
AllowedHostConfig::new(
775777
SchemeConfig::Any,
776-
HostConfig::Cidr(ipnet::IpNet::V6(
777-
ipnet::Ipv6Net::new(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8).unwrap()
778+
HostConfig::Cidr(IpNetwork::V6(
779+
Ipv6Network::new(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8).unwrap()
778780
)),
779781
PortConfig::new(80)
780782
),

0 commit comments

Comments
 (0)