diff --git a/crates/wasi-http/src/authority.rs b/crates/wasi-http/src/authority.rs new file mode 100644 index 000000000000..fed22aebd957 --- /dev/null +++ b/crates/wasi-http/src/authority.rs @@ -0,0 +1,45 @@ +//! Shared validation for wasi-http outgoing request authorities (p2 and p3). + +/// Parse an outgoing request `authority`, rejecting a malformed value. +/// +/// `http::uri::Authority` accepts an authority whose port section is empty or +/// non-numeric (for example `example.com:` or `example.com:abc`), so the port +/// is validated here as well. A `:` inside an IPv6 literal host such as `[::1]` +/// is part of the host rather than a port delimiter, so the port is only looked +/// for after any closing bracket. +pub(crate) fn parse_authority(authority: String) -> Result { + let has_port = match authority.rfind(']') { + Some(i) => authority[i..].contains(':'), + None => authority.contains(':'), + }; + let authority = http::uri::Authority::try_from(authority).map_err(|_| ())?; + if has_port && authority.port_u16().is_none() { + return Err(()); + } + Ok(authority) +} + +#[cfg(test)] +mod tests { + use super::parse_authority; + + #[test] + fn authority_accepts_ipv6_and_validates_ports() { + // Host names and IPv4 literals, with and without an explicit port. + assert!(parse_authority("example.com".into()).is_ok()); + assert!(parse_authority("example.com:443".into()).is_ok()); + assert!(parse_authority("127.0.0.1:80".into()).is_ok()); + + // Bracketed IPv6 literals: the colons belong to the host, so a missing + // port must still be accepted and not mistaken for an empty port. + assert!(parse_authority("[::1]".into()).is_ok()); + assert!(parse_authority("[2001:db8::1]".into()).is_ok()); + assert!(parse_authority("[::1]:443".into()).is_ok()); + + // When a port section is present it must be a valid number. + assert!(parse_authority("example.com:".into()).is_err()); + assert!(parse_authority("example.com:abc".into()).is_err()); + assert!(parse_authority("example.com:65536".into()).is_err()); + assert!(parse_authority("[::1]:abc".into()).is_err()); + } +} diff --git a/crates/wasi-http/src/lib.rs b/crates/wasi-http/src/lib.rs index 2d48ef195a15..88e5c09eb35b 100644 --- a/crates/wasi-http/src/lib.rs +++ b/crates/wasi-http/src/lib.rs @@ -11,6 +11,11 @@ use http::{HeaderName, header}; +#[cfg(any(feature = "p2", feature = "p3"))] +mod authority; +#[cfg(any(feature = "p2", feature = "p3"))] +pub(crate) use authority::parse_authority; + mod ctx; #[cfg(feature = "default-send-request")] mod default_send_request; diff --git a/crates/wasi-http/src/p2/types_impl.rs b/crates/wasi-http/src/p2/types_impl.rs index ba5c3e629ee0..4703948bc631 100644 --- a/crates/wasi-http/src/p2/types_impl.rs +++ b/crates/wasi-http/src/p2/types_impl.rs @@ -333,14 +333,17 @@ impl types::HostOutgoingRequest for WasiHttpCtxView<'_> { ) -> wasmtime::Result> { let req = self.table.get_mut(&request)?; - if let Some(s) = authority.as_ref() { - if let Err(_) = http::uri::Authority::from_str(s.as_str()) { + // Match p3: reject empty / non-numeric / out-of-range ports that + // `http::uri::Authority` alone would accept (see crate::parse_authority). + if let Some(s) = authority { + let Ok(parsed) = crate::parse_authority(s) else { return Ok(Err(())); - } + }; + req.authority = Some(parsed.as_str().into()); + } else { + req.authority = None; } - req.authority = authority; - Ok(Ok(())) } diff --git a/crates/wasi-http/src/p3/host/types.rs b/crates/wasi-http/src/p3/host/types.rs index a0eb84dcdaa1..036c656c7013 100644 --- a/crates/wasi-http/src/p3/host/types.rs +++ b/crates/wasi-http/src/p3/host/types.rs @@ -116,25 +116,6 @@ fn delete_request_options( .context("failed to delete request options from table") } -/// Parse an outgoing request `authority`, rejecting a malformed value. -/// -/// `http::uri::Authority` accepts an authority whose port section is empty or -/// non-numeric (for example `example.com:` or `example.com:abc`), so the port -/// is validated here as well. A `:` inside an IPv6 literal host such as `[::1]` -/// is part of the host rather than a port delimiter, so the port is only looked -/// for after any closing bracket. -fn parse_authority(authority: String) -> Result { - let has_port = match authority.rfind(']') { - Some(i) => authority[i..].contains(':'), - None => authority.contains(':'), - }; - let authority = http::uri::Authority::try_from(authority).map_err(|_| ())?; - if has_port && authority.port_u16().is_none() { - return Err(()); - } - Ok(authority) -} - impl HostFields for WasiHttpCtxView<'_> { fn new(&mut self) -> wasmtime::Result> { push_fields(self.table, FieldMap::new_mutable(self.ctx.field_size_limit)) @@ -366,7 +347,7 @@ impl HostRequest for WasiHttpCtxView<'_> { req.authority = None; return Ok(Ok(())); }; - let Ok(authority) = parse_authority(authority) else { + let Ok(authority) = crate::parse_authority(authority) else { return Ok(Err(())); }; req.authority = Some(authority); @@ -584,26 +565,3 @@ impl Host for WasiHttpCtxView<'_> { error.downcast() } } - -#[cfg(test)] -mod tests { - #[test] - fn authority_accepts_ipv6_and_validates_ports() { - use super::parse_authority; - - // Host names and IPv4 literals, with and without an explicit port. - assert!(parse_authority("example.com".into()).is_ok()); - assert!(parse_authority("example.com:443".into()).is_ok()); - assert!(parse_authority("127.0.0.1:80".into()).is_ok()); - - // Bracketed IPv6 literals: the colons belong to the host, so a missing - // port must still be accepted and not mistaken for an empty port. - assert!(parse_authority("[::1]".into()).is_ok()); - assert!(parse_authority("[2001:db8::1]".into()).is_ok()); - assert!(parse_authority("[::1]:443".into()).is_ok()); - - // When a port section is present it must be a valid number. - assert!(parse_authority("example.com:".into()).is_err()); - assert!(parse_authority("example.com:abc".into()).is_err()); - } -}