From 9f25ef6a0c2bbc20e028d14ce31a7bb464f50b53 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Fri, 2 Oct 2026 18:54:08 +0200 Subject: [PATCH 1/2] Read the chunk-size line strictly in poll-chunked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit poll-chunked trimmed the size line before parsing it, so it accepted ` 5`, `5 ` with no extension, `\t5`, and ` 0 ` as the last chunk. http's TransferEncoding.dechunk has rejected all of these since http#42, which reads the line as RFC 9112 §7.1's `1*HEXDIG [ BWS ";" chunk-ext ]`. This ports its parse-size-line, so the client and the server agree on where a chunk ends. The error message now quotes the whole size line, as dechunk does. --- http-client.carp | 30 ++++++++++++++++++++++++------ test/http-client.carp | 31 +++++++++++++++++++++++++++++++ test/server.py | 12 ++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/http-client.carp b/http-client.carp index bfd3e68..e9495d3 100644 --- a/http-client.carp +++ b/http-client.carp @@ -233,6 +233,27 @@ apart. (set! acc (+ (* acc 16) d))))) (if ok (Maybe.Just acc) (Maybe.Nothing)))) + (hidden bws?) + (private bws?) + (defn bws? [c] (or (= c \space) (= c \tab))) + + (hidden parse-size-line) + (private parse-size-line) + ; chunk-size = 1*HEXDIG, then chunk-ext = *( BWS ";" ... ) per RFC 9112 §7.1 + (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)))) + (hidden crlf-at?) (private crlf-at?) (defn crlf-at? [s i] @@ -340,17 +361,14 @@ apart. @"malformed chunked body: unterminated chunk size line" @"malformed chunked body: missing terminating zero-size chunk"))) (set! running false)) - (let [line &(String.byte-slice (buf s) p eol) - semi (String.index-of line \;) - hex &(String.trim - &(if (= semi -1) @line (String.byte-slice line 0 semi)))] - (match (parse-hex hex) + (let [line &(String.byte-slice (buf s) p eol)] + (match (parse-size-line line) (Maybe.Nothing) (do (fail-parse! s (fmt "malformed chunked body: invalid chunk size '%s'" - hex)) + line)) (set! running false)) (Maybe.Just size) (if (= size 0) diff --git a/test/http-client.carp b/test/http-client.carp index 47ef8eb..714f09b 100644 --- a/test/http-client.carp +++ b/test/http-client.carp @@ -735,6 +735,27 @@ &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-hex-prefix")) "a 0x-prefixed chunk size is rejected") + (assert-equal test + "malformed chunked body: invalid chunk size ' 5'" + &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-leading-space")) + "a space before the chunk size is rejected") + + (assert-equal test + "malformed chunked body: invalid chunk size '5 '" + &(status-and-body + (Client.get "http://127.0.0.1:8791/chunked-trailing-space")) + "a space after the chunk size is rejected when no extension follows") + + (assert-equal test + "malformed chunked body: invalid chunk size '\t5'" + &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-leading-tab")) + "a tab before the chunk size is rejected") + + (assert-equal test + "malformed chunked body: invalid chunk size ' 0 '" + &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-padded-zero")) + "a space-padded zero-size chunk does not end the body") + (assert-equal test "malformed chunked body: truncated chunk data" &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-truncated")) @@ -767,6 +788,16 @@ &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-ext")) "a chunk extension after the size is ignored") + (assert-equal test + "200 [hello]" + &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-ext-space")) + "a space between the size and a chunk extension is allowed") + + (assert-equal test + "200 [hello]" + &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-ext-tab")) + "a tab between the size and a chunk extension is allowed") + (assert-equal test "200 [hello]" &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-trailer")) diff --git a/test/server.py b/test/server.py index 5c70938..7e01ef7 100755 --- a/test/server.py +++ b/test/server.py @@ -209,6 +209,14 @@ def _route(self): return self._chunked_raw(b"5\r\nhello\r\n") if path == "/chunked-missing-crlf": return self._chunked_raw(b"5\r\nhelloXX0\r\n\r\n") + if path == "/chunked-leading-space": + return self._chunked_raw(b" 5\r\nhello\r\n0\r\n\r\n") + if path == "/chunked-trailing-space": + return self._chunked_raw(b"5 \r\nhello\r\n0\r\n\r\n") + if path == "/chunked-leading-tab": + return self._chunked_raw(b"\t5\r\nhello\r\n0\r\n\r\n") + if path == "/chunked-padded-zero": + return self._chunked_raw(b"5\r\nhello\r\n 0 \r\n\r\n") # a chunk size above 16 MiB, whose data is never sent, so the size # line alone decides the outcome @@ -218,6 +226,10 @@ def _route(self): # a well-formed body with a chunk extension and a trailer section if path == "/chunked-ext": return self._chunked_raw(b"5;name=value\r\nhello\r\n0\r\n\r\n") + if path == "/chunked-ext-space": + return self._chunked_raw(b"5 ;name\r\nhello\r\n0\r\n\r\n") + if path == "/chunked-ext-tab": + return self._chunked_raw(b"5\t;name\r\nhello\r\n0\r\n\r\n") if path == "/chunked-trailer": return self._chunked_raw( b"5\r\nhello\r\n0\r\nX-Checksum: abc\r\nX-More: 1\r\n\r\n" From 54fa3385071376b42b1cffc127568adf1b1abede Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 3 Oct 2026 02:49:20 +0200 Subject: [PATCH 2/2] Accept trailing whitespace after the chunk size Chromium, curl, Go and Python all accept a chunk-size line padded with trailing spaces, and Chromium's decoder cites real sites that send one. Rejecting it refused responses main accepted, and a padded `0 ` last chunk threw away a body that had fully arrived. Trailing whitespace cannot move a chunk boundary, and the client is the last parser in its chain, so accepting it opens no desync. Leading whitespace and a stray CR after the size stay rejected; the stray CR now has a test route. This leaves the client one notch looser than http's server-side TransferEncoding.dechunk, which still rejects `5 `. --- http-client.carp | 3 ++- test/http-client.carp | 17 ++++++++++++++--- test/server.py | 4 ++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/http-client.carp b/http-client.carp index e9495d3..2eb86ee 100644 --- a/http-client.carp +++ b/http-client.carp @@ -240,6 +240,7 @@ apart. (hidden parse-size-line) (private parse-size-line) ; chunk-size = 1*HEXDIG, then chunk-ext = *( BWS ";" ... ) per RFC 9112 §7.1 + ; trailing BWS without a chunk-ext is accepted too (defn parse-size-line [line] (let-do [len (String.length line) digits 0 @@ -250,7 +251,7 @@ apart. (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))))) + (or (= i len) (and (< i len) (= \; (String.char-at line i))))) (parse-hex &(String.byte-slice line 0 digits)) (Maybe.Nothing)))) diff --git a/test/http-client.carp b/test/http-client.carp index 714f09b..844e195 100644 --- a/test/http-client.carp +++ b/test/http-client.carp @@ -741,20 +741,31 @@ "a space before the chunk size is rejected") (assert-equal test - "malformed chunked body: invalid chunk size '5 '" + "200 [hello]" &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-trailing-space")) - "a space after the chunk size is rejected when no extension follows") + "a space after the chunk size is allowed") (assert-equal test "malformed chunked body: invalid chunk size '\t5'" &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-leading-tab")) "a tab before the chunk size is rejected") + (assert-equal test + "malformed chunked body: invalid chunk size '5\r'" + &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-stray-cr")) + "a stray CR after the chunk size is rejected") + (assert-equal test "malformed chunked body: invalid chunk size ' 0 '" &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-padded-zero")) - "a space-padded zero-size chunk does not end the body") + "a zero-size chunk with a leading space does not end the body") + + (assert-equal test + "200 [hello]" + &(status-and-body + (Client.get "http://127.0.0.1:8791/chunked-zero-trailing-space")) + "a zero-size chunk with a trailing space ends the body") (assert-equal test "malformed chunked body: truncated chunk data" diff --git a/test/server.py b/test/server.py index 7e01ef7..639e29f 100755 --- a/test/server.py +++ b/test/server.py @@ -215,8 +215,12 @@ def _route(self): return self._chunked_raw(b"5 \r\nhello\r\n0\r\n\r\n") if path == "/chunked-leading-tab": return self._chunked_raw(b"\t5\r\nhello\r\n0\r\n\r\n") + if path == "/chunked-stray-cr": + return self._chunked_raw(b"5\r\r\nhello\r\n0\r\n\r\n") if path == "/chunked-padded-zero": return self._chunked_raw(b"5\r\nhello\r\n 0 \r\n\r\n") + if path == "/chunked-zero-trailing-space": + return self._chunked_raw(b"5\r\nhello\r\n0 \r\n\r\n") # a chunk size above 16 MiB, whose data is never sent, so the size # line alone decides the outcome