Skip to content

Commit a0a9456

Browse files
sec(l7): reject duplicate Content-Length headers to prevent request smuggling
Both parse_body_length() in rest.rs and try_parse_http_request() in inference.rs silently accepted multiple Content-Length headers, overwriting with the last value seen. Per RFC 7230 Section 3.3.3, a message with multiple Content-Length headers with differing values must be rejected to prevent HTTP request smuggling (CWE-444). An attacker could send conflicting Content-Length values causing the proxy and downstream server to disagree on message boundaries. Fix: - rest.rs: detect duplicate CL headers with differing values and return an error before forwarding - inference.rs: add ParseResult::Invalid variant; detect duplicate CL headers and return Invalid with a descriptive reason - proxy.rs: handle ParseResult::Invalid by sending HTTP 400 and denying the connection Closes #637
1 parent a7ebf3a commit a0a9456

File tree

3 files changed

+23
-1
lines changed

3 files changed

+23
-1
lines changed

crates/openshell-sandbox/src/l7/inference.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ pub enum ParseResult {
9696
Complete(ParsedHttpRequest, usize),
9797
/// Headers are incomplete — caller should read more data.
9898
Incomplete,
99+
/// The request is malformed and must be rejected (e.g., duplicate Content-Length).
100+
Invalid(String),
99101
}
100102

101103
/// Try to parse an HTTP/1.1 request from raw bytes.
@@ -125,6 +127,7 @@ pub fn try_parse_http_request(buf: &[u8]) -> ParseResult {
125127

126128
let mut headers = Vec::new();
127129
let mut content_length: usize = 0;
130+
let mut has_content_length = false;
128131
let mut is_chunked = false;
129132
for line in lines {
130133
if line.is_empty() {
@@ -134,7 +137,14 @@ pub fn try_parse_http_request(buf: &[u8]) -> ParseResult {
134137
let name = name.trim().to_string();
135138
let value = value.trim().to_string();
136139
if name.eq_ignore_ascii_case("content-length") {
137-
content_length = value.parse().unwrap_or(0);
140+
let new_len: usize = value.parse().unwrap_or(0);
141+
if has_content_length && new_len != content_length {
142+
return ParseResult::Invalid(format!(
143+
"duplicate Content-Length headers with differing values ({content_length} vs {new_len})"
144+
));
145+
}
146+
content_length = new_len;
147+
has_content_length = true;
138148
}
139149
if name.eq_ignore_ascii_case("transfer-encoding")
140150
&& value

crates/openshell-sandbox/src/l7/rest.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,13 @@ fn parse_body_length(headers: &str) -> Result<BodyLength> {
250250
&& let Some(val) = lower.split_once(':').map(|(_, v)| v.trim())
251251
&& let Ok(len) = val.parse::<u64>()
252252
{
253+
if let Some(prev) = cl_value {
254+
if prev != len {
255+
return Err(miette!(
256+
"Request contains multiple Content-Length headers with differing values ({prev} vs {len})"
257+
));
258+
}
259+
}
253260
cl_value = Some(len);
254261
}
255262
}

crates/openshell-sandbox/src/proxy.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,11 @@ async fn handle_inference_interception(
943943
buf.resize((buf.len() * 2).min(MAX_INFERENCE_BUF), 0);
944944
}
945945
}
946+
ParseResult::Invalid(reason) => {
947+
let response = format_http_response(400, &[], reason.as_bytes());
948+
write_all(&mut tls_client, &response).await?;
949+
return Ok(InferenceOutcome::Denied { reason });
950+
}
946951
}
947952
}
948953

0 commit comments

Comments
 (0)