Reject whitespace-padded XHTTP client header values

HTTP/1 receivers strip leading and trailing whitespace from field values
and HTTP/2 and HTTP/3 forbid it, so such a configured value is never sent
intact and can never satisfy a server's matching_headers. Apply the
server's existing rule to client headers through one shared value check.
This commit is contained in:
cfal
2026-10-22 06:15:09 +00:00
parent 1b6a0b7f48
commit cf20454595
2 changed files with 28 additions and 12 deletions
+1 -1
View File
@@ -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
+27 -11
View File
@@ -129,6 +129,23 @@ fn validate_header_names<'a>(
Ok(())
}
fn validate_header_values<'a>(
field: &str,
values: impl IntoIterator<Item = &'a String>,
) -> 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"}