Skip to content

Commit f794e25

Browse files
fix(l7): reject requests with both CL and TE headers in inference parser (CWE-444)
The CL/TE desynchronisation guard added in #663 for the REST path was not applied to the inference request parser. A request containing both Content-Length and Transfer-Encoding headers could be interpreted differently by the proxy and the upstream server, enabling HTTP request smuggling (CWE-444, RFC 7230 Section 3.3.3). Add the same rejection check and tests mirroring the REST parser coverage, including TE substring validation. Signed-off-by: latenighthackathon <latenighthackathon@users.noreply.github.com>
1 parent 0ac1fbd commit f794e25

File tree

1 file changed

+57
-0
lines changed

1 file changed

+57
-0
lines changed

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ pub fn try_parse_http_request(buf: &[u8]) -> ParseResult {
164164
}
165165
}
166166

167+
if is_chunked && has_content_length {
168+
return ParseResult::Invalid(
169+
"Request contains both Transfer-Encoding and Content-Length headers".to_string(),
170+
);
171+
}
172+
167173
let (body, consumed) = if is_chunked {
168174
let Some((decoded_body, consumed)) = parse_chunked_body(buf, body_start) else {
169175
return ParseResult::Incomplete;
@@ -570,6 +576,24 @@ mod tests {
570576
assert_eq!(parsed.body.len(), 100);
571577
}
572578

579+
/// SEC: Transfer-Encoding substring match must not match partial tokens.
580+
#[test]
581+
fn te_substring_not_chunked() {
582+
let body = r#"{"model":"m","messages":[]}"#;
583+
let request = format!(
584+
"POST /v1/chat/completions HTTP/1.1\r\n\
585+
Host: x\r\n\
586+
Transfer-Encoding: chunkedx\r\n\
587+
Content-Length: {}\r\n\
588+
\r\n{body}",
589+
body.len(),
590+
);
591+
let ParseResult::Complete(parsed, _) = try_parse_http_request(request.as_bytes()) else {
592+
panic!("expected Complete for non-matching TE with valid CL");
593+
};
594+
assert_eq!(parsed.body.len(), body.len());
595+
}
596+
573597
// ---- SEC: Content-Length validation ----
574598

575599
#[test]
@@ -608,4 +632,37 @@ mod tests {
608632
ParseResult::Invalid(_)
609633
));
610634
}
635+
636+
// ---- SEC-009: CL/TE desynchronisation ----
637+
638+
/// Reject requests with both Content-Length and Transfer-Encoding to
639+
/// prevent CL/TE request smuggling (RFC 7230 Section 3.3.3).
640+
#[test]
641+
fn reject_dual_content_length_and_transfer_encoding() {
642+
let request = b"POST /v1/chat/completions HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n";
643+
assert!(
644+
matches!(
645+
try_parse_http_request(request),
646+
ParseResult::Invalid(reason)
647+
if reason.contains("Transfer-Encoding")
648+
&& reason.contains("Content-Length")
649+
),
650+
"Must reject request with both CL and TE"
651+
);
652+
}
653+
654+
/// Same rejection regardless of header order.
655+
#[test]
656+
fn reject_dual_transfer_encoding_and_content_length() {
657+
let request = b"POST /v1/chat/completions HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\n";
658+
assert!(
659+
matches!(
660+
try_parse_http_request(request),
661+
ParseResult::Invalid(reason)
662+
if reason.contains("Transfer-Encoding")
663+
&& reason.contains("Content-Length")
664+
),
665+
"Must reject request with both TE and CL"
666+
);
667+
}
611668
}

0 commit comments

Comments
 (0)