-
Notifications
You must be signed in to change notification settings - Fork 263
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
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, | ||
|
@@ -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>, | ||
|
@@ -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)); | ||
} | ||
} 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 | ||
|
@@ -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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
|
@@ -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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
@@ -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 | ||
}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This whole diff chunk is basically just switching from |
||
} | ||
}); | ||
} | ||
|
@@ -122,6 +137,7 @@ impl Factor for OutboundNetworkingFactor { | |
Ok(InstanceBuilder { | ||
allowed_hosts_future, | ||
component_tls_configs, | ||
disallowed_host_callback: self.disallowed_host_callback, | ||
}) | ||
} | ||
} | ||
|
@@ -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, | ||
} | ||
} | ||
|
||
|
@@ -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 { | ||
|
@@ -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) | ||
} | ||
} |
There was a problem hiding this comment.
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 tooutbound_allowed_hosts.report_disallowed_host
here, no?I see later on that
report_disallowed_host
gets called automatically incheck_url
. Doing it in thecheck_url
call but not inallows_relative_url
is confusing. I think we should encapsulate the call toreport_disallowed_host
in the same equivalent places for both relative and non-relative requests.There was a problem hiding this comment.
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.