From 394e9b962ad93c25477fd8fe2ebcd074528474f2 Mon Sep 17 00:00:00 2001 From: "Claude (sandbox)" Date: Sun, 9 Aug 2026 07:59:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(parser):=20reject=20duplicate=20Content-Le?= =?UTF-8?q?ngth=20(RFC=209112=20=C2=A76.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/parser.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/parser.rs b/src/parser.rs index 0c135e8..364c878 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -102,6 +102,7 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result Result { + // 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::() @@ -151,6 +156,13 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result 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");