diff --git a/http-client.carp b/http-client.carp index bfd3e68..2eb86ee 100644 --- a/http-client.carp +++ b/http-client.carp @@ -233,6 +233,28 @@ 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 + ; trailing BWS without a chunk-ext is accepted too + (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 (= i 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 +362,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..844e195 100644 --- a/test/http-client.carp +++ b/test/http-client.carp @@ -735,6 +735,38 @@ &(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 + "200 [hello]" + &(status-and-body + (Client.get "http://127.0.0.1:8791/chunked-trailing-space")) + "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 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" &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-truncated")) @@ -767,6 +799,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..639e29f 100755 --- a/test/server.py +++ b/test/server.py @@ -209,6 +209,18 @@ 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-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 @@ -218,6 +230,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"