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")