feat(parser): reject duplicate Content-Length (RFC 9112 §6.3)
The content-length arm ran content_length = Some(parse) per header, so a second Content-Length silently overwrote the first with no conflict check (CL.CL request smuggling; h1spec #21 -> 404 instead of 400). Count occurrences and reject any duplicate post-loop, strictly (even equal values), reusing BadContentLength (400). A single value is still required to be one decimal integer, so a comma-list or non-numeric keeps failing at parse as before. Tests: differing dup, equal dup, single-CL regression.
This commit is contained in:
@@ -102,6 +102,7 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
||||
let mut expect_100 = false;
|
||||
let mut host_count = 0usize;
|
||||
let mut host_ok = true;
|
||||
let mut cl_count = 0usize;
|
||||
|
||||
for h in req.headers.iter() {
|
||||
let name_lower = h.name.to_ascii_lowercase();
|
||||
@@ -109,6 +110,10 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
||||
|
||||
match name_lower.as_str() {
|
||||
"content-length" => {
|
||||
// Count occurrences; duplicates (even equal) are rejected
|
||||
// post-loop. A single value must be one decimal integer —
|
||||
// a comma-list ("5, 5") or non-numeric fails parse here.
|
||||
cl_count += 1;
|
||||
content_length = Some(
|
||||
value.trim()
|
||||
.parse::<usize>()
|
||||
@@ -151,6 +156,13 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
||||
return Err(ParseError::Malformed);
|
||||
}
|
||||
|
||||
// Content-Length (RFC 9112 §6.3): more than one Content-Length is an
|
||||
// unrecoverable framing ambiguity (CL.CL request smuggling). We are
|
||||
// strict — reject any duplicate, not only differing values.
|
||||
if cl_count > 1 {
|
||||
return Err(ParseError::BadContentLength);
|
||||
}
|
||||
|
||||
if chunked {
|
||||
// Transfer-Encoding is an HTTP/1.1 mechanism; a 1.0 request
|
||||
// carrying it is malformed. And a request carrying BOTH a
|
||||
@@ -498,6 +510,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content-Length (RFC 9112 §6.3) ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_conflicting_content_length_is_rejected() {
|
||||
// Two differing Content-Length values — classic CL.CL smuggling.
|
||||
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 7\r\n\r\nhello!!";
|
||||
match parse_head(req, 64) {
|
||||
Err(ParseError::BadContentLength) => {}
|
||||
_ => panic!("expected BadContentLength for conflicting CL"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duplicate_equal_content_length_is_rejected() {
|
||||
// Strict: even identical duplicates are rejected.
|
||||
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\nhello";
|
||||
match parse_head(req, 64) {
|
||||
Err(ParseError::BadContentLength) => {}
|
||||
_ => panic!("expected BadContentLength for duplicate CL"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_content_length_still_ok() {
|
||||
// Regression: the ordinary single-CL path is unchanged.
|
||||
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello";
|
||||
let head = parse_head(req, 64).unwrap();
|
||||
assert_eq!(head.content_length, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_basic_200() {
|
||||
let conn = Conn::new().put_status(200).put_body("hi");
|
||||
|
||||
Reference in New Issue
Block a user