Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

factors: Update outbound networking #2737

Merged
merged 3 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 132 additions & 56 deletions Cargo.lock

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion crates/factor-outbound-http/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@ pub use wasmtime_wasi_http::{
HttpResult,
};

pub struct OutboundHttpFactor;
#[derive(Default)]
pub struct OutboundHttpFactor {
_priv: (),
}

impl OutboundHttpFactor {
pub fn new() -> Self {
Self::default()
}
}

impl Factor for OutboundHttpFactor {
type RuntimeConfig = ();
Expand Down
65 changes: 31 additions & 34 deletions crates/factor-outbound-http/src/wasi.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
use std::{error::Error, sync::Arc};

use anyhow::Context;
use http::{header::HOST, uri::Authority, Request, Uri};
use http::{header::HOST, Request};
use http_body_util::BodyExt;
use rustls::ClientConfig;
use spin_factor_outbound_networking::{OutboundAllowedHosts, OutboundUrl};
use spin_factor_outbound_networking::OutboundAllowedHosts;
use spin_factors::{wasmtime::component::ResourceTable, RuntimeFactorsInstanceState};
use tokio::{net::TcpStream, time::timeout};
use tracing::Instrument;
use tracing::{field::Empty, instrument, Instrument};
use wasmtime_wasi_http::{
bindings::http::types::ErrorCode,
body::HyperOutgoingBody,
Expand Down Expand Up @@ -68,6 +68,19 @@ impl<'a> WasiHttpView for WasiHttpImplInner<'a> {
self.table
}

#[instrument(
name = "spin_outbound_http.send_request",
skip_all,
fields(
otel.kind = "client",
url.full = %request.uri(),
http.request.method = %request.method(),
otel.name = %request.method(),
http.response.status_code = Empty,
server.address = Empty,
server.port = Empty,
),
)]
fn send_request(
&mut self,
mut request: Request<wasmtime_wasi_http::body::HyperOutgoingBody>,
Expand Down Expand Up @@ -104,15 +117,24 @@ impl<'a> WasiHttpView for WasiHttpImplInner<'a> {
async fn send_request_impl(
mut request: Request<wasmtime_wasi_http::body::HyperOutgoingBody>,
mut config: wasmtime_wasi_http::types::OutgoingRequestConfig,
allowed_hosts: OutboundAllowedHosts,
outbound_allowed_hosts: OutboundAllowedHosts,
tls_client_config: Arc<ClientConfig>,
) -> anyhow::Result<Result<IncomingResponse, ErrorCode>> {
let allowed_hosts = allowed_hosts.resolve().await?;

let is_relative_url = request.uri().authority().is_none();
if is_relative_url {
if request.uri().authority().is_some() {
// Absolute URI
let is_allowed = outbound_allowed_hosts
.check_url(&request.uri().to_string(), "https")
.await
.map_err(|_| ErrorCode::HttpRequestUriInvalid)?;
if !is_allowed {
return Ok(Err(ErrorCode::HttpRequestDenied));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're missing a call to outbound_allowed_hosts.report_disallowed_host here, no?

I see later on that report_disallowed_host gets called automatically in check_url. Doing it in the check_url call but not in allows_relative_url is confusing. I think we should encapsulate the call to report_disallowed_host in the same equivalent places for both relative and non-relative requests.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reworked this significantly; see the latest commit.

}
} else {
// Relative URI ("self" request)
let allowed_hosts = outbound_allowed_hosts.resolve().await?;
if !allowed_hosts.allows_relative_url(&["http", "https"]) {
return Ok(handle_not_allowed(request.uri(), true));
outbound_allowed_hosts.report_disallowed_host("http", "self");
return Ok(Err(ErrorCode::HttpRequestDenied));
}

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

let path_and_query = request.uri().path_and_query().cloned();
*request.uri_mut() = origin.into_uri(path_and_query);
} else {
let outbound_url = OutboundUrl::parse(request.uri().to_string(), "https")
.map_err(|_| ErrorCode::HttpRequestUriInvalid)?;
if !allowed_hosts.allows(&outbound_url) {
return Ok(handle_not_allowed(request.uri(), false));
}
}

if let Some(authority) = request.uri().authority() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I know this was here before, but it seems strange we have two different blocks for when request.uri().authority() is Some instead of just combining them.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm...authority should always be set here. I'll change it to an error.

Expand All @@ -146,25 +162,6 @@ async fn send_request_impl(
Ok(send_request_handler(request, config, tls_client_config).await)
}

// TODO(factors): Move to some callback on spin-factor-outbound-networking (?)
fn handle_not_allowed(uri: &Uri, is_relative: bool) -> Result<IncomingResponse, ErrorCode> {
tracing::error!("Destination not allowed!: {uri}");
let allowed_host_example = if is_relative {
terminal::warn!("A component tried to make a HTTP request to the same component but it does not have permission.");
"http://self".to_string()
} else {
let host = format!(
"{scheme}://{authority}",
scheme = uri.scheme_str().unwrap_or_default(),
authority = uri.authority().map(Authority::as_str).unwrap_or_default()
);
terminal::warn!("A component tried to make a HTTP request to non-allowed host '{host}'.");
host
};
eprintln!("To allow requests, add 'allowed_outbound_hosts = [\"{allowed_host_example}\"]' to the manifest component section.");
Err(ErrorCode::HttpRequestDenied)
}

/// This is a fork of wasmtime_wasi_http::default_send_request_handler function
/// forked from bytecodealliance/wasmtime commit-sha 29a76b68200fcfa69c8fb18ce6c850754279a05b
/// This fork provides the ability to configure client cert auth for mTLS
Expand Down
4 changes: 2 additions & 2 deletions crates/factor-outbound-http/tests/factor_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ async fn test_instance_state(
) -> anyhow::Result<TestFactorsInstanceState> {
let factors = TestFactors {
variables: VariablesFactor::default(),
networking: OutboundNetworkingFactor,
http: OutboundHttpFactor,
networking: OutboundNetworkingFactor::new(),
http: OutboundHttpFactor::new(),
};
let env = TestEnvironment::new(factors).extend_manifest(toml! {
[component.test-component]
Expand Down
2 changes: 1 addition & 1 deletion crates/factor-outbound-mqtt/tests/factor_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ struct TestFactors {
fn factors() -> TestFactors {
TestFactors {
variables: VariablesFactor::default(),
networking: OutboundNetworkingFactor,
networking: OutboundNetworkingFactor::new(),
mqtt: OutboundMqttFactor::new(Arc::new(MockMqttClient {})),
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/factor-outbound-mysql/tests/factor_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct TestFactors {
fn factors() -> TestFactors {
TestFactors {
variables: VariablesFactor::default(),
networking: OutboundNetworkingFactor,
networking: OutboundNetworkingFactor::new(),
mysql: OutboundMysqlFactor::<MockClient>::new(),
}
}
Expand Down
90 changes: 66 additions & 24 deletions crates/factor-outbound-networking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,24 @@ pub use runtime_config::ComponentTlsConfigs;

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

pub struct OutboundNetworkingFactor;
#[derive(Default)]
pub struct OutboundNetworkingFactor {
disallowed_host_callback: Option<DisallowedHostCallback>,
}

pub type DisallowedHostCallback = fn(scheme: &str, authority: &str);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's more typing, but should we only limit this to function pointers instead of any impl Fn(&str, &str)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh but its so much more typing 😐


impl OutboundNetworkingFactor {
pub fn new() -> Self {
Self::default()
}

/// Sets a function to be called when a request is disallowed by an
/// instance's configured `allowed_outbound_hosts`.
pub fn set_disallowed_host_callback(&mut self, callback: DisallowedHostCallback) {
self.disallowed_host_callback = Some(callback);
}
}

impl Factor for OutboundNetworkingFactor {
type RuntimeConfig = RuntimeConfig;
Expand Down Expand Up @@ -87,26 +104,24 @@ impl Factor for OutboundNetworkingFactor {
match builders.get_mut::<WasiFactor>() {
Ok(wasi_builder) => {
// Update Wasi socket allowed ports
let hosts_future = allowed_hosts_future.clone();
let allowed_hosts = OutboundAllowedHosts {
allowed_hosts_future: allowed_hosts_future.clone(),
disallowed_host_callback: self.disallowed_host_callback,
};
wasi_builder.outbound_socket_addr_check(move |addr, addr_use| {
let hosts_future = hosts_future.clone();
let allowed_hosts = allowed_hosts.clone();
async move {
match hosts_future.await {
Ok(allowed_hosts) => {
// TODO: validate against existing spin-core behavior
let scheme = match addr_use {
SocketAddrUse::TcpBind => return false,
SocketAddrUse::TcpConnect => "tcp",
SocketAddrUse::UdpBind | SocketAddrUse::UdpConnect | SocketAddrUse::UdpOutgoingDatagram => "udp",
};
spin_outbound_networking::check_url(&addr.to_string(),scheme, &allowed_hosts)
}
Err(err) => {
// TODO: should this trap (somehow)?
tracing::error!(%err, "allowed_outbound_hosts variable resolution failed");
false
}
}
// TODO: validate against existing spin-core behavior
let scheme = match addr_use {
SocketAddrUse::TcpBind => return false,
SocketAddrUse::TcpConnect => "tcp",
SocketAddrUse::UdpBind | SocketAddrUse::UdpConnect | SocketAddrUse::UdpOutgoingDatagram => "udp",
};
allowed_hosts.check_url(&addr.to_string(), scheme).await.unwrap_or_else(|err| {
// TODO: should this trap (somehow)?
tracing::error!(%err, "allowed_outbound_hosts variable resolution failed");
false
})
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole diff chunk is basically just switching from spin_outbound_networking::check_url to OutboundAllowedHosts::check_url.

}
});
}
Expand All @@ -122,6 +137,7 @@ impl Factor for OutboundNetworkingFactor {
Ok(InstanceBuilder {
allowed_hosts_future,
component_tls_configs,
disallowed_host_callback: self.disallowed_host_callback,
})
}
}
Expand All @@ -134,12 +150,14 @@ pub struct AppState {
pub struct InstanceBuilder {
allowed_hosts_future: SharedFutureResult<AllowedHostsConfig>,
component_tls_configs: ComponentTlsConfigs,
disallowed_host_callback: Option<DisallowedHostCallback>,
}

impl InstanceBuilder {
pub fn allowed_hosts(&self) -> OutboundAllowedHosts {
OutboundAllowedHosts {
allowed_hosts_future: self.allowed_hosts_future.clone(),
disallowed_host_callback: self.disallowed_host_callback,
}
}

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

impl OutboundAllowedHosts {
Expand All @@ -170,16 +189,39 @@ impl OutboundAllowedHosts {
})
}

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

/// Report that an outbound connection has been disallowed by e.g.
/// [`OutboundAllowedHosts::allows`] returning `false`.
///
/// Calls the [`DisallowedHostCallback`] if set.
pub fn report_disallowed_host(&self, scheme: &str, authority: &str) {
if let Some(disallowed_host_callback) = self.disallowed_host_callback {
disallowed_host_callback(scheme, authority);
}
}

/// Checks address against allowed hosts
///
/// Calls the [`DisallowedHostCallback`] if set and URL is disallowed.
pub async fn check_url(&self, url: &str, scheme: &str) -> anyhow::Result<bool> {
let Ok(url) = OutboundUrl::parse(url, scheme) else {
tracing::warn!(
"A component tried to make a request to a url that could not be parsed: {url}",
);
return Ok(false);
};

let allowed_hosts = self.resolve().await?;
Ok(spin_outbound_networking::check_url(
url,
scheme,
&allowed_hosts,
))

let is_allowed = allowed_hosts.allows(&url);
if !is_allowed {
self.report_disallowed_host(url.scheme(), &url.authority());
}
Ok(is_allowed)
}
}
2 changes: 1 addition & 1 deletion crates/factor-outbound-networking/src/runtime_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ mod tests {
Ok(())
}

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

fn test_certs() -> anyhow::Result<Vec<CertificateDer<'static>>> {
let file = std::fs::File::open(Path::new(TESTDATA_DIR).join("valid-cert.pem"))?;
Expand Down
7 changes: 5 additions & 2 deletions crates/factor-outbound-networking/src/runtime_config/spin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,10 @@ impl SpinTlsRuntimeConfig {
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("private key file '{}' contains no private keys", path.display()),
format!(
"private key file '{}' contains no private keys",
path.display()
),
)
})?)
}
Expand Down Expand Up @@ -184,7 +187,7 @@ fn deserialize_hosts<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<S
mod tests {
use super::*;

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

#[test]
fn test_min_config() -> anyhow::Result<()> {
Expand Down
4 changes: 2 additions & 2 deletions crates/factor-outbound-networking/tests/factor_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async fn configures_wasi_socket_addr_check() -> anyhow::Result<()> {
let factors = TestFactors {
wasi: WasiFactor::new(DummyFilesMounter),
variables: VariablesFactor::default(),
networking: OutboundNetworkingFactor,
networking: OutboundNetworkingFactor::new(),
};
let env = TestEnvironment::new(factors).extend_manifest(toml! {
[component.test-component]
Expand Down Expand Up @@ -58,7 +58,7 @@ async fn wasi_factor_is_optional() -> anyhow::Result<()> {
}
TestEnvironment::new(WithoutWasi {
variables: VariablesFactor::default(),
networking: OutboundNetworkingFactor,
networking: OutboundNetworkingFactor::new(),
})
.build_instance_state()
.await?;
Expand Down
2 changes: 1 addition & 1 deletion crates/factor-outbound-pg/tests/factor_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ struct TestFactors {
fn factors() -> TestFactors {
TestFactors {
variables: VariablesFactor::default(),
networking: OutboundNetworkingFactor,
networking: OutboundNetworkingFactor::new(),
pg: OutboundPgFactor::<MockClient>::new(),
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/factor-outbound-redis/tests/factor_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ struct TestFactors {
async fn no_outbound_hosts_fails() -> anyhow::Result<()> {
let factors = TestFactors {
variables: VariablesFactor::default(),
networking: OutboundNetworkingFactor,
networking: OutboundNetworkingFactor::new(),
redis: OutboundRedisFactor::new(),
};
let env = TestEnvironment::new(factors).extend_manifest(toml! {
Expand Down
4 changes: 2 additions & 2 deletions crates/factors/tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ async fn smoke_test_works() -> anyhow::Result<()> {
let mut factors = Factors {
wasi: WasiFactor::new(DummyFilesMounter),
variables: VariablesFactor::default(),
outbound_networking: OutboundNetworkingFactor,
outbound_http: OutboundHttpFactor,
outbound_networking: OutboundNetworkingFactor::new(),
outbound_http: OutboundHttpFactor::new(),
key_value: KeyValueFactor::new(key_value_resolver.clone()),
};

Expand Down
Loading
Loading