diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e74e063..875800e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - Updated Rust dependencies, including GotaTun 0.10.2, Tokio 1.54.0, LRU 0.19.0, and ML-KEM 0.3.3, which removes secret-dependent table lookups. Existing Web PKI compromise protections are retained. - Refreshed the BLAKE3 fork with upstream buffer-alignment and AVX-512 soundness fixes while retaining the binary-context key derivation API. - Updated Android and CI build dependencies; Android source builds now require Gradle 9.6.0 or newer with Java 17. -- XHTTP server `matching_headers` values may now be lists. A request must still send exactly one value, which may match any listed value, so secrets can rotate without downtime. XHTTP client `headers` now reject names that differ only in case. Such configs previously sent every value, which a server with `matching_headers` always rejects. +- XHTTP server `matching_headers` values may now be lists. A request must still send exactly one value, which may match any listed value, so secrets can rotate without downtime. XHTTP client `headers` now reject names that differ only in case, which previously sent every value and always failed a server's `matching_headers`, and values with leading or trailing whitespace, which receivers strip or reject. - Removed XHTTP server `close_on_http1_error`; configurations that still set it fail to load. HTTP/1 header and path mismatches now return the same plain 404 as HTTP/2 and HTTP/3. All XHTTP error responses now omit XHTTP padding, caching, and CORS headers, not only 400 and 404; successful responses are unchanged. ## v0.3.4 diff --git a/src/config/types/xhttp.rs b/src/config/types/xhttp.rs index 76a9c439..5a00c583 100644 --- a/src/config/types/xhttp.rs +++ b/src/config/types/xhttp.rs @@ -129,6 +129,23 @@ fn validate_header_names<'a>( Ok(()) } +fn validate_header_values<'a>( + field: &str, + values: impl IntoIterator, +) -> io::Result<()> { + for value in values { + http::HeaderValue::try_from(value).map_err(io::Error::other)?; + // HTTP/1 receivers strip edge whitespace and HTTP/2 and HTTP/3 forbid it, + // so such a value never arrives as configured. + if value.starts_with([' ', '\t']) || value.ends_with([' ', '\t']) { + return Err(invalid(&format!( + "XHTTP {field} values cannot start or end with whitespace" + ))); + } + } + Ok(()) +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct XhttpLimits { @@ -339,9 +356,7 @@ impl XhttpClientConfig { return Err(invalid("direct cannot be an XHTTP inner protocol")); } validate_header_names("headers", self.headers.keys())?; - for value in self.headers.values() { - http::HeaderValue::try_from(value).map_err(io::Error::other)?; - } + validate_header_values("headers", self.headers.values())?; Ok(()) } } @@ -380,14 +395,10 @@ impl XhttpServerConfig { pub fn validate(&self) -> io::Result<()> { validate_path_and_host(&self.path, self.host.as_deref())?; validate_header_names("matching_headers", self.matching_headers.keys())?; - for value in self.matching_headers.values().flat_map(OneOrSome::iter) { - http::HeaderValue::try_from(value).map_err(io::Error::other)?; - if value.starts_with([' ', '\t']) || value.ends_with([' ', '\t']) { - return Err(invalid( - "XHTTP matching header values cannot start or end with whitespace", - )); - } - } + validate_header_values( + "matching_headers", + self.matching_headers.values().flat_map(OneOrSome::iter), + )?; if contains_server(&self.protocol) { return Err(invalid("nested XHTTP is not supported")); } @@ -652,6 +663,11 @@ mod tests { (serde_json::json!({"x-token": "a\r\nx-injected: b"}), false), (serde_json::json!({"Host": "front.test"}), false), (serde_json::json!({"Connection": "keep-alive"}), false), + (serde_json::json!({"x-token": "a b\tc"}), true), + (serde_json::json!({"x-token": " a"}), false), + (serde_json::json!({"x-token": "a "}), false), + (serde_json::json!({"x-token": "\ta"}), false), + (serde_json::json!({"x-token": "a\t"}), false), ] { let config: XhttpClientConfig = serde_json::from_value(serde_json::json!({ "headers": headers, "protocol": {"type": "socks"}