From 0e8bffb2ebdcd12f98df25d8bcf4c843d42dad63 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Wed, 26 Aug 2026 12:27:30 +0200 Subject: [PATCH] Read the chunk-size line strictly as 1*HEXDIG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dechunk trimmed the size line before parsing it, so it decoded four framings RFC 9112 §7.1 has no production for: " 5", "5 ", "\t5" and " 0 " all decoded on main. §11.2 names that leniency as the request-smuggling primitive, and `web` decodes untrusted client request bodies through this function (web-decode-body answers 400 on an Error), so the server accepted framing a conformant proxy in front of it would reject or split differently. parse-size-line now takes the size verbatim: the maximal HEXDIG run at the head of the line, which must be non-empty, and after it the line must either end or continue into a chunk extension. The BWS that §7.1.1 allows before the `;` is still accepted, since the grammar has it; trailing whitespace with no extension behind it is not. A stray CR before the terminating CRLF ("5\r\r\n") is rejected for the same reason, where the trim used to swallow it. Uppercase hex, leading zeros, extensions, trailer sections and multi-chunk bodies are unchanged, and the error message now quotes the whole offending line. Five of the six new rejection assertions fail on main; the sixth (a bare ";name=value" with no digits before it) pins behaviour parse-hex already had. 488 passed / 0 failed. --- docs/TransferEncoding.html | 6 ++++-- http.carp | 38 ++++++++++++++++++++++++++++---------- test/http.carp | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/docs/TransferEncoding.html b/docs/TransferEncoding.html index c18716a..6dfc20a 100644 --- a/docs/TransferEncoding.html +++ b/docs/TransferEncoding.html @@ -216,12 +216,14 @@

(dechunk body)

-

decodes a chunked transfer-encoded body per RFC 7230 §4.1.

+

decodes a chunked transfer-encoded body per RFC 9112 §7.1.

Each chunk is a hexadecimal size line — any chunk extension after a ; is ignored — followed by that many bytes of data and a CRLF, repeating until a zero-size chunk. Optional trailer headers after the final chunk are ignored. Returns the decoded body, or an (Error String) on malformed framing such as a -bad hex size or a truncated chunk.

+bad hex size or a truncated chunk. The size line is read strictly: it must begin +with a hexadecimal digit, and nothing but a chunk extension may follow the +size.

diff --git a/http.carp b/http.carp index 1b2aeaf..495a035 100644 --- a/http.carp +++ b/http.carp @@ -661,19 +661,42 @@ Use `Request.chunked?`/`Response.chunked?` to detect a chunked body, then (set! acc (+ (* acc 16) d))))) (if ok (Maybe.Just acc) (Maybe.Nothing)))) + (private bws?) + (hidden bws?) + (defn bws? [c] (or (= c \space) (= c \tab))) + + ; chunk-size = 1*HEXDIG, then chunk-ext = *( BWS ";" ... ) per RFC 9112 §7.1 + (private parse-size-line) + (hidden parse-size-line) + (defn parse-size-line [line] + (let-do [len (String.length line) + digits 0 + i 0] + (while (and (< digits len) + (>= (hex-nibble (String.char-at line digits)) 0)) + (set! digits (Int.inc digits))) + (set! i digits) + (while (and (< i len) (bws? (String.char-at line i))) (set! i (Int.inc i))) + (if (and (> digits 0) + (or (= digits len) (and (< i len) (= \; (String.char-at line i))))) + (parse-hex &(String.byte-slice line 0 digits)) + (Maybe.Nothing)))) + (private crlf-at?) (hidden crlf-at?) (defn crlf-at? [s i] (and (= (String.char-at s i) \return) (= (String.char-at s (Int.inc i)) \newline))) - (doc dechunk "decodes a chunked transfer-encoded `body` per RFC 7230 §4.1. + (doc dechunk "decodes a chunked transfer-encoded `body` per RFC 9112 §7.1. Each chunk is a hexadecimal size line — any chunk extension after a `;` is ignored — followed by that many bytes of data and a CRLF, repeating until a zero-size chunk. Optional trailer headers after the final chunk are ignored. Returns the decoded body, or an `(Error String)` on malformed framing such as a -bad hex size or a truncated chunk.") +bad hex size or a truncated chunk. The size line is read strictly: it must begin +with a hexadecimal digit, and nothing but a chunk extension may follow the +size.") (defn dechunk [body] (let-do [len (String.length body) pos 0 @@ -684,18 +707,13 @@ bad hex size or a truncated chunk.") (let [eol (String.find-crlf body pos len)] (if (= eol -1) (set! err @"malformed chunked body: unterminated chunk size line") - (let [size-line &(String.byte-slice body pos eol) - semi (String.index-of size-line \;) - hex &(String.trim - &(if (= semi -1) - @size-line - (String.byte-slice size-line 0 semi)))] - (match (parse-hex hex) + (let [size-line &(String.byte-slice body pos eol)] + (match (parse-size-line size-line) (Maybe.Nothing) (set! err (fmt "malformed chunked body: invalid chunk size '%s'" - hex)) + size-line)) (Maybe.Just size) (if (= size 0) (set! terminated true) diff --git a/test/http.carp b/test/http.carp index a274628..e5a5085 100644 --- a/test/http.carp +++ b/test/http.carp @@ -2195,6 +2195,40 @@ (dechunk-err? "4\r\nWiki\r\n5\r\npedia\r\n") "dechunk errors on a missing terminating zero-size chunk") + (assert-true test + (dechunk-err? " 5\r\nhello\r\n0\r\n\r\n") + "dechunk rejects a chunk size line with leading whitespace") + + (assert-true test + (dechunk-err? "\t5\r\nhello\r\n0\r\n\r\n") + "dechunk rejects a chunk size line starting with a tab") + + (assert-true test + (dechunk-err? "5 \r\nhello\r\n0\r\n\r\n") + "dechunk rejects a chunk size with trailing whitespace and no extension") + + (assert-true test + (dechunk-err? "5\r\r\nhello\r\n0\r\n\r\n") + "dechunk rejects a stray CR between the chunk size and the CRLF") + + (assert-true test + (dechunk-err? " 0 \r\n\r\n") + "dechunk rejects a whitespace-padded terminating chunk") + + (assert-true test + (dechunk-err? ";name=value\r\nhello\r\n0\r\n\r\n") + "dechunk rejects a chunk extension with no size before it") + + (assert-equal test + &(dechunk-ok "5 ;name=value\r\nhello\r\n0\r\n\r\n") + "hello" + "dechunk allows the bad whitespace RFC 9112 §7.1.1 permits before an extension") + + (assert-equal test + &(dechunk-ok "1C\r\nabcdefghijklmnopqrstuvwxyz01\r\n0\r\n\r\n") + "abcdefghijklmnopqrstuvwxyz01" + "dechunk decodes an uppercase hex chunk size") + (assert-equal test &(match (Response.parse "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n")