Skip to content

Commit 8ad00cc

Browse files
authored
Merge pull request #2808 from fermyon/allow-private-ips
Give outbound http ability to ban private ips
2 parents 57b1e54 + 166b337 commit 8ad00cc

File tree

7 files changed

+119
-47
lines changed

7 files changed

+119
-47
lines changed

Cargo.lock

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

crates/factor-outbound-http/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ anyhow = "1.0"
99
http = "1.1.0"
1010
http-body-util = "0.1"
1111
hyper = "1.4.1"
12+
ip_network = "0.4"
1213
reqwest = { version = "0.11", features = ["gzip"] }
1314
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
1415
spin-factor-outbound-networking = { path = "../factor-outbound-networking" }
1516
spin-factors = { path = "../factors" }
1617
spin-telemetry = { path = "../telemetry" }
1718
spin-world = { path = "../world" }
1819
terminal = { path = "../terminal" }
19-
tokio = { version = "1", features = ["macros", "rt"] }
20+
tokio = { version = "1", features = ["macros", "rt", "net"] }
2021
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12"] }
2122
tracing = { workspace = true }
2223
wasmtime = { workspace = true }

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,24 @@ pub use wasmtime_wasi_http::{
2424
HttpResult,
2525
};
2626

27-
#[derive(Default)]
2827
pub struct OutboundHttpFactor {
29-
_priv: (),
28+
allow_private_ips: bool,
3029
}
3130

3231
impl OutboundHttpFactor {
33-
pub fn new() -> Self {
34-
Self::default()
32+
/// Create a new OutboundHttpFactor.
33+
///
34+
/// If `allow_private_ips` is true, requests to private IP addresses will be allowed.
35+
pub fn new(allow_private_ips: bool) -> Self {
36+
Self { allow_private_ips }
37+
}
38+
}
39+
40+
impl Default for OutboundHttpFactor {
41+
fn default() -> Self {
42+
Self {
43+
allow_private_ips: true,
44+
}
3545
}
3646
}
3747

@@ -66,6 +76,7 @@ impl Factor for OutboundHttpFactor {
6676
Ok(InstanceState {
6777
wasi_http_ctx: WasiHttpCtx::new(),
6878
allowed_hosts,
79+
allow_private_ips: self.allow_private_ips,
6980
component_tls_configs,
7081
self_request_origin: None,
7182
request_interceptor: None,
@@ -77,6 +88,7 @@ impl Factor for OutboundHttpFactor {
7788
pub struct InstanceState {
7889
wasi_http_ctx: WasiHttpCtx,
7990
allowed_hosts: OutboundAllowedHosts,
91+
allow_private_ips: bool,
8092
component_tls_configs: ComponentTlsConfigs,
8193
self_request_origin: Option<SelfRequestOrigin>,
8294
request_interceptor: Option<Box<dyn OutboundHttpInterceptor>>,

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

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

33
use anyhow::Context;
44
use http::{header::HOST, Request};
55
use http_body_util::BodyExt;
6+
use ip_network::IpNetwork;
67
use rustls::ClientConfig;
78
use spin_factor_outbound_networking::OutboundAllowedHosts;
89
use spin_factors::{wasmtime::component::ResourceTable, RuntimeFactorsInstanceState};
@@ -123,6 +124,7 @@ impl<'a> WasiHttpView for WasiHttpImplInner<'a> {
123124
self.state.allowed_hosts.clone(),
124125
self.state.self_request_origin.clone(),
125126
tls_client_config,
127+
self.state.allow_private_ips,
126128
)
127129
.in_current_span(),
128130
),
@@ -136,6 +138,7 @@ async fn send_request_impl(
136138
outbound_allowed_hosts: OutboundAllowedHosts,
137139
self_request_origin: Option<SelfRequestOrigin>,
138140
tls_client_config: Arc<ClientConfig>,
141+
allow_private_ips: bool,
139142
) -> anyhow::Result<Result<IncomingResponse, ErrorCode>> {
140143
if request.uri().authority().is_some() {
141144
// Absolute URI
@@ -177,7 +180,7 @@ async fn send_request_impl(
177180
current_span.record("server.port", port.as_u16());
178181
}
179182

180-
Ok(send_request_handler(request, config, tls_client_config).await)
183+
Ok(send_request_handler(request, config, tls_client_config, allow_private_ips).await)
181184
}
182185

183186
/// This is a fork of wasmtime_wasi_http::default_send_request_handler function
@@ -192,6 +195,7 @@ async fn send_request_handler(
192195
between_bytes_timeout,
193196
}: wasmtime_wasi_http::types::OutgoingRequestConfig,
194197
tls_client_config: Arc<ClientConfig>,
198+
allow_private_ips: bool,
195199
) -> Result<wasmtime_wasi_http::types::IncomingResponse, ErrorCode> {
196200
let authority_str = if let Some(authority) = request.uri().authority() {
197201
if authority.port().is_some() {
@@ -204,23 +208,26 @@ async fn send_request_handler(
204208
return Err(ErrorCode::HttpRequestUriInvalid);
205209
};
206210

207-
let tcp_stream = timeout(connect_timeout, TcpStream::connect(&authority_str))
211+
// Resolve the authority to IP addresses
212+
let mut socket_addrs = tokio::net::lookup_host(&authority_str)
213+
.await
214+
.map_err(|_| dns_error("address not available".into(), 0))?
215+
.collect::<Vec<_>>();
216+
217+
// Potentially filter out private IPs
218+
if !allow_private_ips && !socket_addrs.is_empty() {
219+
socket_addrs.retain(|addr| !is_private_ip(addr.ip()));
220+
if socket_addrs.is_empty() {
221+
return Err(ErrorCode::DestinationIpProhibited);
222+
}
223+
}
224+
225+
let tcp_stream = timeout(connect_timeout, TcpStream::connect(socket_addrs.as_slice()))
208226
.await
209227
.map_err(|_| ErrorCode::ConnectionTimeout)?
210228
.map_err(|err| match err.kind() {
211-
std::io::ErrorKind::AddrNotAvailable => {
212-
dns_error("address not available".to_string(), 0)
213-
}
214-
_ => {
215-
if err
216-
.to_string()
217-
.starts_with("failed to lookup address information")
218-
{
219-
dns_error("address not available".to_string(), 0)
220-
} else {
221-
ErrorCode::ConnectionRefused
222-
}
223-
}
229+
std::io::ErrorKind::AddrNotAvailable => dns_error("address not available".into(), 0),
230+
_ => ErrorCode::ConnectionRefused,
224231
})?;
225232

226233
let (mut sender, worker) = if use_tls {
@@ -337,3 +344,8 @@ fn dns_error(rcode: String, info_code: u16) -> ErrorCode {
337344
info_code: Some(info_code),
338345
})
339346
}
347+
348+
/// Returns true if the IP is a private IP address.
349+
fn is_private_ip(ip: IpAddr) -> bool {
350+
!IpNetwork::from(ip).is_global()
351+
}

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ struct TestFactors {
2121

2222
#[tokio::test]
2323
async fn allowed_host_is_allowed() -> anyhow::Result<()> {
24-
let mut state = test_instance_state("https://*").await?;
24+
let mut state = test_instance_state("https://*", true).await?;
2525
let mut wasi_http = OutboundHttpFactor::get_wasi_http_impl(&mut state).unwrap();
2626

2727
// [100::] is an IPv6 "black hole", which should always fail
@@ -39,7 +39,7 @@ async fn allowed_host_is_allowed() -> anyhow::Result<()> {
3939

4040
#[tokio::test]
4141
async fn self_request_smoke_test() -> anyhow::Result<()> {
42-
let mut state = test_instance_state("http://self").await?;
42+
let mut state = test_instance_state("http://self", true).await?;
4343
let origin = SelfRequestOrigin::from_uri(&Uri::from_static("http://[100::1]"))?;
4444
state.http.set_self_request_origin(origin);
4545

@@ -58,7 +58,7 @@ async fn self_request_smoke_test() -> anyhow::Result<()> {
5858

5959
#[tokio::test]
6060
async fn disallowed_host_fails() -> anyhow::Result<()> {
61-
let mut state = test_instance_state("https://allowed.test").await?;
61+
let mut state = test_instance_state("https://allowed.test", true).await?;
6262
let mut wasi_http = OutboundHttpFactor::get_wasi_http_impl(&mut state).unwrap();
6363

6464
let req = Request::get("https://denied.test").body(Default::default())?;
@@ -71,13 +71,47 @@ async fn disallowed_host_fails() -> anyhow::Result<()> {
7171
Ok(())
7272
}
7373

74+
#[tokio::test]
75+
async fn disallowed_private_ips_fails() -> anyhow::Result<()> {
76+
async fn run_test(allow_private_ips: bool) -> anyhow::Result<()> {
77+
let mut state = test_instance_state("http://*", allow_private_ips).await?;
78+
let mut wasi_http = OutboundHttpFactor::get_wasi_http_impl(&mut state).unwrap();
79+
let req = Request::get("http://localhost").body(Default::default())?;
80+
let mut future_resp = wasi_http.send_request(req, test_request_config())?;
81+
future_resp.ready().await;
82+
match future_resp.unwrap_ready().unwrap() {
83+
// If we don't allow private IPs, we should not get a response
84+
Ok(_) if !allow_private_ips => bail!("expected Err, got Ok"),
85+
// Otherwise, it's fine if the request happens to succeed
86+
Ok(_) => {}
87+
// If private IPs are disallowed, we should get an error saying the destination is prohibited
88+
Err(err) if !allow_private_ips => {
89+
assert!(matches!(err, ErrorCode::DestinationIpProhibited))
90+
}
91+
// Otherwise, we should get some non-DestinationIpProhibited error
92+
Err(err) => {
93+
assert!(!matches!(err, ErrorCode::DestinationIpProhibited))
94+
}
95+
};
96+
Ok(())
97+
}
98+
99+
// Test with private IPs allowed
100+
run_test(true).await?;
101+
// Test with private IPs disallowed
102+
run_test(false).await?;
103+
104+
Ok(())
105+
}
106+
74107
async fn test_instance_state(
75108
allowed_outbound_hosts: &str,
109+
allow_private_ips: bool,
76110
) -> anyhow::Result<TestFactorsInstanceState> {
77111
let factors = TestFactors {
78112
variables: VariablesFactor::default(),
79113
networking: OutboundNetworkingFactor::new(),
80-
http: OutboundHttpFactor::new(),
114+
http: OutboundHttpFactor::new(allow_private_ips),
81115
};
82116
let env = TestEnvironment::new(factors).extend_manifest(toml! {
83117
[component.test-component]

crates/runtime-factors/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ impl TriggerFactors {
4949
variables: VariablesFactor::default(),
5050
key_value: KeyValueFactor::new(default_key_value_label_resolver),
5151
outbound_networking: outbound_networking_factor(),
52-
outbound_http: OutboundHttpFactor::new(),
52+
outbound_http: OutboundHttpFactor::default(),
5353
sqlite: SqliteFactor::new(default_sqlite_label_resolver),
5454
redis: OutboundRedisFactor::new(),
5555
mqtt: OutboundMqttFactor::new(NetworkedMqttClient::creator()),

0 commit comments

Comments
 (0)