diff --git a/crates/wasi-http/src/lib.rs b/crates/wasi-http/src/lib.rs index b61c11c6466c..0b40aa8694a2 100644 --- a/crates/wasi-http/src/lib.rs +++ b/crates/wasi-http/src/lib.rs @@ -33,10 +33,44 @@ fn get_content_length(headers: &http::HeaderMap) -> wasmtime::Result return Ok(None); }; let v = v.to_str()?; + // RFC 9110 defines `Content-Length` as `1*DIGIT`. `u64`'s `FromStr` is more + // lenient and also accepts a leading `+`, so reject anything that isn't a + // non-empty run of decimal digits before parsing. + if v.is_empty() || !v.bytes().all(|b| b.is_ascii_digit()) { + wasmtime::bail!("invalid `content-length` header value: {v:?}"); + } let v = v.parse()?; Ok(Some(v)) } +#[cfg(all(test, any(feature = "p2", feature = "p3")))] +mod content_length_tests { + use super::get_content_length; + use http::{HeaderMap, HeaderValue, header}; + + fn headers(value: &str) -> HeaderMap { + let mut map = HeaderMap::new(); + map.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(value).unwrap(), + ); + map + } + + #[test] + fn content_length_must_be_decimal_digits() { + assert_eq!(get_content_length(&HeaderMap::new()).unwrap(), None); + assert_eq!(get_content_length(&headers("0")).unwrap(), Some(0)); + assert_eq!(get_content_length(&headers("1234")).unwrap(), Some(1234)); + + // `u64::from_str` accepts these but they are not `1*DIGIT` per RFC 9110. + assert!(get_content_length(&headers("+5")).is_err()); + assert!(get_content_length(&headers("-5")).is_err()); + assert!(get_content_length(&headers(" 5")).is_err()); + assert!(get_content_length(&headers("")).is_err()); + } +} + /// Set of [http::header::HeaderName], that are forbidden by default /// for requests and responses originating in the guest. pub const DEFAULT_FORBIDDEN_HEADERS: [HeaderName; 9] = [ diff --git a/crates/wasi-http/src/p3/host/types.rs b/crates/wasi-http/src/p3/host/types.rs index 29cbaff2f70c..252951688a0c 100644 --- a/crates/wasi-http/src/p3/host/types.rs +++ b/crates/wasi-http/src/p3/host/types.rs @@ -129,6 +129,12 @@ fn parse_header_value( ) -> Result { if name == CONTENT_LENGTH { let s = str::from_utf8(value.as_ref()).or(Err(HeaderError::InvalidSyntax))?; + // RFC 9110 defines `Content-Length` as `1*DIGIT`. `u64`'s `FromStr` is + // more lenient and also accepts a leading `+`, so reject anything that + // isn't a non-empty run of decimal digits. + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return Err(HeaderError::InvalidSyntax); + } let v: u64 = s.parse().or(Err(HeaderError::InvalidSyntax))?; Ok(v.into()) } else { @@ -705,3 +711,24 @@ impl Host for WasiHttpCtxView<'_> { error.downcast() } } + +#[cfg(test)] +mod tests { + use super::parse_header_value; + use http::header::{CONTENT_LENGTH, CONTENT_TYPE}; + + #[test] + fn content_length_rejects_non_digits() { + assert!(parse_header_value(&CONTENT_LENGTH, "0").is_ok()); + assert!(parse_header_value(&CONTENT_LENGTH, "1234").is_ok()); + + // `u64::from_str` accepts these but they are not `1*DIGIT` per RFC 9110. + assert!(parse_header_value(&CONTENT_LENGTH, "+5").is_err()); + assert!(parse_header_value(&CONTENT_LENGTH, "-5").is_err()); + assert!(parse_header_value(&CONTENT_LENGTH, " 5").is_err()); + assert!(parse_header_value(&CONTENT_LENGTH, "").is_err()); + + // other header names are unaffected + assert!(parse_header_value(&CONTENT_TYPE, "text/plain").is_ok()); + } +}