Skip to content

Commit 4237a81

Browse files
committed
factors: Update outbound networking
Signed-off-by: Lann Martin <[email protected]>
1 parent 66f357a commit 4237a81

File tree

16 files changed

+294
-157
lines changed

16 files changed

+294
-157
lines changed

Cargo.lock

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

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@ pub use wasmtime_wasi_http::{
2525
HttpResult,
2626
};
2727

28-
pub struct OutboundHttpFactor;
28+
#[derive(Default)]
29+
pub struct OutboundHttpFactor {
30+
_priv: (),
31+
}
32+
33+
impl OutboundHttpFactor {
34+
pub fn new() -> Self {
35+
Self::default()
36+
}
37+
}
2938

3039
impl Factor for OutboundHttpFactor {
3140
type RuntimeConfig = ();

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

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

33
use anyhow::Context;
4-
use http::{header::HOST, uri::Authority, Request, Uri};
4+
use http::{header::HOST, Request};
55
use http_body_util::BodyExt;
66
use rustls::ClientConfig;
7-
use spin_factor_outbound_networking::{OutboundAllowedHosts, OutboundUrl};
7+
use spin_factor_outbound_networking::OutboundAllowedHosts;
88
use spin_factors::{wasmtime::component::ResourceTable, RuntimeFactorsInstanceState};
99
use tokio::{net::TcpStream, time::timeout};
10-
use tracing::Instrument;
10+
use tracing::{field::Empty, instrument, Instrument};
1111
use wasmtime_wasi_http::{
1212
bindings::http::types::ErrorCode,
1313
body::HyperOutgoingBody,
@@ -68,6 +68,19 @@ impl<'a> WasiHttpView for WasiHttpImplInner<'a> {
6868
self.table
6969
}
7070

71+
#[instrument(
72+
name = "spin_outbound_http.send_request",
73+
skip_all,
74+
fields(
75+
otel.kind = "client",
76+
url.full = %request.uri(),
77+
http.request.method = %request.method(),
78+
otel.name = %request.method(),
79+
http.response.status_code = Empty,
80+
server.address = Empty,
81+
server.port = Empty,
82+
),
83+
)]
7184
fn send_request(
7285
&mut self,
7386
mut request: Request<wasmtime_wasi_http::body::HyperOutgoingBody>,
@@ -104,15 +117,24 @@ impl<'a> WasiHttpView for WasiHttpImplInner<'a> {
104117
async fn send_request_impl(
105118
mut request: Request<wasmtime_wasi_http::body::HyperOutgoingBody>,
106119
mut config: wasmtime_wasi_http::types::OutgoingRequestConfig,
107-
allowed_hosts: OutboundAllowedHosts,
120+
outbound_allowed_hosts: OutboundAllowedHosts,
108121
tls_client_config: Arc<ClientConfig>,
109122
) -> anyhow::Result<Result<IncomingResponse, ErrorCode>> {
110-
let allowed_hosts = allowed_hosts.resolve().await?;
111-
112-
let is_relative_url = request.uri().authority().is_none();
113-
if is_relative_url {
123+
if request.uri().authority().is_some() {
124+
// Absolute URI
125+
let is_allowed = outbound_allowed_hosts
126+
.check_url(&request.uri().to_string(), "https")
127+
.await
128+
.map_err(|_| ErrorCode::HttpRequestUriInvalid)?;
129+
if !is_allowed {
130+
return Ok(Err(ErrorCode::HttpRequestDenied));
131+
}
132+
} else {
133+
// Relative URI ("self" request)
134+
let allowed_hosts = outbound_allowed_hosts.resolve().await?;
114135
if !allowed_hosts.allows_relative_url(&["http", "https"]) {
115-
return Ok(handle_not_allowed(request.uri(), true));
136+
outbound_allowed_hosts.report_disallowed_host("http", "self");
137+
return Ok(Err(ErrorCode::HttpRequestDenied));
116138
}
117139

118140
let origin = request
@@ -127,12 +149,6 @@ async fn send_request_impl(
127149

128150
let path_and_query = request.uri().path_and_query().cloned();
129151
*request.uri_mut() = origin.into_uri(path_and_query);
130-
} else {
131-
let outbound_url = OutboundUrl::parse(request.uri().to_string(), "https")
132-
.map_err(|_| ErrorCode::HttpRequestUriInvalid)?;
133-
if !allowed_hosts.allows(&outbound_url) {
134-
return Ok(handle_not_allowed(request.uri(), false));
135-
}
136152
}
137153

138154
if let Some(authority) = request.uri().authority() {
@@ -146,25 +162,6 @@ async fn send_request_impl(
146162
Ok(send_request_handler(request, config, tls_client_config).await)
147163
}
148164

149-
// TODO(factors): Move to some callback on spin-factor-outbound-networking (?)
150-
fn handle_not_allowed(uri: &Uri, is_relative: bool) -> Result<IncomingResponse, ErrorCode> {
151-
tracing::error!("Destination not allowed!: {uri}");
152-
let allowed_host_example = if is_relative {
153-
terminal::warn!("A component tried to make a HTTP request to the same component but it does not have permission.");
154-
"http://self".to_string()
155-
} else {
156-
let host = format!(
157-
"{scheme}://{authority}",
158-
scheme = uri.scheme_str().unwrap_or_default(),
159-
authority = uri.authority().map(Authority::as_str).unwrap_or_default()
160-
);
161-
terminal::warn!("A component tried to make a HTTP request to non-allowed host '{host}'.");
162-
host
163-
};
164-
eprintln!("To allow requests, add 'allowed_outbound_hosts = [\"{allowed_host_example}\"]' to the manifest component section.");
165-
Err(ErrorCode::HttpRequestDenied)
166-
}
167-
168165
/// This is a fork of wasmtime_wasi_http::default_send_request_handler function
169166
/// forked from bytecodealliance/wasmtime commit-sha 29a76b68200fcfa69c8fb18ce6c850754279a05b
170167
/// This fork provides the ability to configure client cert auth for mTLS

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ async fn test_instance_state(
7777
) -> anyhow::Result<TestFactorsInstanceState> {
7878
let factors = TestFactors {
7979
variables: VariablesFactor::default(),
80-
networking: OutboundNetworkingFactor,
81-
http: OutboundHttpFactor,
80+
networking: OutboundNetworkingFactor::new(),
81+
http: OutboundHttpFactor::new(),
8282
};
8383
let env = TestEnvironment::new(factors).extend_manifest(toml! {
8484
[component.test-component]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ struct TestFactors {
4646
fn factors() -> TestFactors {
4747
TestFactors {
4848
variables: VariablesFactor::default(),
49-
networking: OutboundNetworkingFactor,
49+
networking: OutboundNetworkingFactor::new(),
5050
mqtt: OutboundMqttFactor::new(Arc::new(MockMqttClient {})),
5151
}
5252
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ struct TestFactors {
2020
fn factors() -> TestFactors {
2121
TestFactors {
2222
variables: VariablesFactor::default(),
23-
networking: OutboundNetworkingFactor,
23+
networking: OutboundNetworkingFactor::new(),
2424
mysql: OutboundMysqlFactor::<MockClient>::new(),
2525
}
2626
}

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

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,24 @@ pub use runtime_config::ComponentTlsConfigs;
2222

2323
pub type SharedFutureResult<T> = Shared<BoxFuture<'static, Result<Arc<T>, Arc<anyhow::Error>>>>;
2424

25-
pub struct OutboundNetworkingFactor;
25+
#[derive(Default)]
26+
pub struct OutboundNetworkingFactor {
27+
disallowed_host_callback: Option<DisallowedHostCallback>,
28+
}
29+
30+
pub type DisallowedHostCallback = fn(scheme: &str, authority: &str);
31+
32+
impl OutboundNetworkingFactor {
33+
pub fn new() -> Self {
34+
Self::default()
35+
}
36+
37+
/// Sets a function to be called when a request is disallowed by an
38+
/// instance's configured `allowed_outbound_hosts`.
39+
pub fn set_disallowed_host_callback(&mut self, callback: DisallowedHostCallback) {
40+
self.disallowed_host_callback = Some(callback);
41+
}
42+
}
2643

2744
impl Factor for OutboundNetworkingFactor {
2845
type RuntimeConfig = RuntimeConfig;
@@ -87,26 +104,24 @@ impl Factor for OutboundNetworkingFactor {
87104
match builders.get_mut::<WasiFactor>() {
88105
Ok(wasi_builder) => {
89106
// Update Wasi socket allowed ports
90-
let hosts_future = allowed_hosts_future.clone();
107+
let allowed_hosts = OutboundAllowedHosts {
108+
allowed_hosts_future: allowed_hosts_future.clone(),
109+
disallowed_host_callback: self.disallowed_host_callback,
110+
};
91111
wasi_builder.outbound_socket_addr_check(move |addr, addr_use| {
92-
let hosts_future = hosts_future.clone();
112+
let allowed_hosts = allowed_hosts.clone();
93113
async move {
94-
match hosts_future.await {
95-
Ok(allowed_hosts) => {
96-
// TODO: validate against existing spin-core behavior
97-
let scheme = match addr_use {
98-
SocketAddrUse::TcpBind => return false,
99-
SocketAddrUse::TcpConnect => "tcp",
100-
SocketAddrUse::UdpBind | SocketAddrUse::UdpConnect | SocketAddrUse::UdpOutgoingDatagram => "udp",
101-
};
102-
spin_outbound_networking::check_url(&addr.to_string(),scheme, &allowed_hosts)
103-
}
104-
Err(err) => {
105-
// TODO: should this trap (somehow)?
106-
tracing::error!(%err, "allowed_outbound_hosts variable resolution failed");
107-
false
108-
}
109-
}
114+
// TODO: validate against existing spin-core behavior
115+
let scheme = match addr_use {
116+
SocketAddrUse::TcpBind => return false,
117+
SocketAddrUse::TcpConnect => "tcp",
118+
SocketAddrUse::UdpBind | SocketAddrUse::UdpConnect | SocketAddrUse::UdpOutgoingDatagram => "udp",
119+
};
120+
allowed_hosts.check_url(&addr.to_string(), scheme).await.unwrap_or_else(|err| {
121+
// TODO: should this trap (somehow)?
122+
tracing::error!(%err, "allowed_outbound_hosts variable resolution failed");
123+
false
124+
})
110125
}
111126
});
112127
}
@@ -122,6 +137,7 @@ impl Factor for OutboundNetworkingFactor {
122137
Ok(InstanceBuilder {
123138
allowed_hosts_future,
124139
component_tls_configs,
140+
disallowed_host_callback: self.disallowed_host_callback,
125141
})
126142
}
127143
}
@@ -134,12 +150,14 @@ pub struct AppState {
134150
pub struct InstanceBuilder {
135151
allowed_hosts_future: SharedFutureResult<AllowedHostsConfig>,
136152
component_tls_configs: ComponentTlsConfigs,
153+
disallowed_host_callback: Option<DisallowedHostCallback>,
137154
}
138155

139156
impl InstanceBuilder {
140157
pub fn allowed_hosts(&self) -> OutboundAllowedHosts {
141158
OutboundAllowedHosts {
142159
allowed_hosts_future: self.allowed_hosts_future.clone(),
160+
disallowed_host_callback: self.disallowed_host_callback,
143161
}
144162
}
145163

@@ -160,6 +178,7 @@ impl FactorInstanceBuilder for InstanceBuilder {
160178
#[derive(Clone)]
161179
pub struct OutboundAllowedHosts {
162180
allowed_hosts_future: SharedFutureResult<AllowedHostsConfig>,
181+
disallowed_host_callback: Option<DisallowedHostCallback>,
163182
}
164183

165184
impl OutboundAllowedHosts {
@@ -170,16 +189,39 @@ impl OutboundAllowedHosts {
170189
})
171190
}
172191

192+
/// Checks if the given URL is allowed by this component's
193+
/// `allowed_outbound_hosts`.
173194
pub async fn allows(&self, url: &OutboundUrl) -> anyhow::Result<bool> {
174195
Ok(self.resolve().await?.allows(url))
175196
}
176197

198+
/// Report that an outbound connection has been disallowed by e.g.
199+
/// [`OutboundAllowedHosts::allows`] returning `false`.
200+
///
201+
/// Calls the [`DisallowedHostCallback`] if set.
202+
pub fn report_disallowed_host(&self, scheme: &str, authority: &str) {
203+
if let Some(disallowed_host_callback) = self.disallowed_host_callback {
204+
disallowed_host_callback(scheme, authority);
205+
}
206+
}
207+
208+
/// Checks address against allowed hosts
209+
///
210+
/// Calls the [`DisallowedHostCallback`] if set and URL is disallowed.
177211
pub async fn check_url(&self, url: &str, scheme: &str) -> anyhow::Result<bool> {
212+
let Ok(url) = OutboundUrl::parse(url, scheme) else {
213+
tracing::warn!(
214+
"A component tried to make a request to a url that could not be parsed: {url}",
215+
);
216+
return Ok(false);
217+
};
218+
178219
let allowed_hosts = self.resolve().await?;
179-
Ok(spin_outbound_networking::check_url(
180-
url,
181-
scheme,
182-
&allowed_hosts,
183-
))
220+
221+
let is_allowed = allowed_hosts.allows(&url);
222+
if !is_allowed {
223+
self.report_disallowed_host(url.scheme(), &url.authority());
224+
}
225+
Ok(is_allowed)
184226
}
185227
}

crates/factor-outbound-networking/src/runtime_config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ mod tests {
253253
Ok(())
254254
}
255255

256-
const TESTDATA_DIR: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata");
256+
const TESTDATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata");
257257

258258
fn test_certs() -> anyhow::Result<Vec<CertificateDer<'static>>> {
259259
let file = std::fs::File::open(Path::new(TESTDATA_DIR).join("valid-cert.pem"))?;

crates/factor-outbound-networking/src/runtime_config/spin.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,10 @@ impl SpinTlsRuntimeConfig {
154154
.ok_or_else(|| {
155155
io::Error::new(
156156
io::ErrorKind::InvalidInput,
157-
format!("private key file '{}' contains no private keys", path.display()),
157+
format!(
158+
"private key file '{}' contains no private keys",
159+
path.display()
160+
),
158161
)
159162
})?)
160163
}
@@ -184,7 +187,7 @@ fn deserialize_hosts<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<S
184187
mod tests {
185188
use super::*;
186189

187-
const TESTDATA_DIR: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata");
190+
const TESTDATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata");
188191

189192
#[test]
190193
fn test_min_config() -> anyhow::Result<()> {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ async fn configures_wasi_socket_addr_check() -> anyhow::Result<()> {
1717
let factors = TestFactors {
1818
wasi: WasiFactor::new(DummyFilesMounter),
1919
variables: VariablesFactor::default(),
20-
networking: OutboundNetworkingFactor,
20+
networking: OutboundNetworkingFactor::new(),
2121
};
2222
let env = TestEnvironment::new(factors).extend_manifest(toml! {
2323
[component.test-component]
@@ -58,7 +58,7 @@ async fn wasi_factor_is_optional() -> anyhow::Result<()> {
5858
}
5959
TestEnvironment::new(WithoutWasi {
6060
variables: VariablesFactor::default(),
61-
networking: OutboundNetworkingFactor,
61+
networking: OutboundNetworkingFactor::new(),
6262
})
6363
.build_instance_state()
6464
.await?;

0 commit comments

Comments
 (0)