Skip to content

Commit 10710de

Browse files
authored
Enforce the chunk-size grammar in a single pass (#661)
#631 validated the chunk-size line twice: a hand-rolled byte walk (hex_digits?/1, all_hex_digits?/1) followed by Integer.parse/2. The overlap between the two is total once the first check passes: RFC9112 section 7.1 defines chunk-size as 1*HEXDIG, and Integer.parse/2's only divergence from that grammar is that it additionally accepts a single leading '+' or '-'. A sign can only ever appear in the first byte, so once hex_digits?/1 confirms every byte is a hex digit, Integer.parse/2 on that string cannot return anything but {n, ""} - the with clause's second branch was unreachable as a failure path. Collapse the validator into one function with two clauses: a guard on the leading byte closes the sign gap directly, and the existing {chunk_size, ""} match still rules out trailing junk. The accepted language is unchanged. Verified byte-for-byte equivalence between the old and new validators across 41 handpicked adversarial inputs, 200,000 randomized strings over an adversarial alphabet, and 500,000 raw random byte strings (not restricted to valid UTF-8) - zero divergences. All four of #631's regression tests pass unchanged. One new test pins a case that was an explicit clause before (empty chunk-size) but is now implicit in the pattern match: a chunk-ext with no preceding size digits. Credit: this refactor was proposed in a review comment on #631 by @mpettibone (#631 (comment)). I independently re-verified the equivalence claims (including with a separate implementation and additional raw-byte fuzzing) before applying it here.
1 parent d69202e commit 10710de

2 files changed

Lines changed: 36 additions & 25 deletions

File tree

lib/bandit/http1/socket.ex

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -358,36 +358,28 @@ defmodule Bandit.HTTP1.Socket do
358358
{parse_chunk_size!(chunk_size), rest}
359359
end
360360

361-
# RFC9112§7.1 defines chunk-size as 1*HEXDIG. Integer.parse/2 additionally
362-
# accepts a leading '+' or '-', so the grammar has to be checked separately
363-
# rather than inferred from the parse succeeding. Without this, '+10' reads
364-
# as a sixteen byte chunk, and '-0' parses as zero and is taken for the
365-
# terminating chunk, ending the body at a position no conforming parser
366-
# would end it at. A negative size reaches read_exactly!/5, which is
367-
# guarded on a non-negative length and so raises FunctionClauseError rather
368-
# than returning a 400.
361+
# RFC9112§7.1 defines chunk-size as 1*HEXDIG. Integer.parse/2 is close to that grammar but
362+
# not equal to it: the only extra thing it will consume is a single leading '+' or '-'.
363+
# Without ruling that out, '+10' reads as a sixteen byte chunk, and '-0' parses as zero and
364+
# is taken for the terminating chunk, ending the body at a position no conforming parser
365+
# would end it at. A negative size reaches read_exactly!/5, which is guarded on a
366+
# non-negative length and so raises FunctionClauseError rather than returning a 400.
367+
#
368+
# A sign can only ever appear as the first byte, so anchoring the guard there closes the
369+
# gap: once the first byte is a hex digit, the only remaining way Integer.parse/2 can
370+
# disagree with the grammar is trailing junk (e.g. "5g"), which the {chunk_size, ""} match
371+
# already rules out by requiring the parse to consume every byte. The empty string can't
372+
# match the leading-byte pattern either, so it falls through to the catch-all clause.
369373
@spec parse_chunk_size!(binary()) :: non_neg_integer()
370-
defp parse_chunk_size!(chunk_size) do
371-
with true <- hex_digits?(chunk_size),
372-
{chunk_size, ""} <- Integer.parse(chunk_size, 16) do
373-
chunk_size
374-
else
374+
defp parse_chunk_size!(<<digit, _::binary>> = chunk_size)
375+
when digit in ?0..?9 or digit in ?a..?f or digit in ?A..?F do
376+
case Integer.parse(chunk_size, 16) do
377+
{chunk_size, ""} -> chunk_size
375378
_ -> request_error!("Unable to parse chunk size")
376379
end
377380
end
378381

379-
@spec hex_digits?(binary()) :: boolean()
380-
defp hex_digits?(<<>>), do: false
381-
defp hex_digits?(chunk_size), do: all_hex_digits?(chunk_size)
382-
383-
@spec all_hex_digits?(binary()) :: boolean()
384-
defp all_hex_digits?(<<>>), do: true
385-
386-
defp all_hex_digits?(<<digit, rest::binary>>)
387-
when digit in ?0..?9 or digit in ?a..?f or digit in ?A..?F,
388-
do: all_hex_digits?(rest)
389-
390-
defp all_hex_digits?(_chunk_size), do: false
382+
defp parse_chunk_size!(_chunk_size), do: request_error!("Unable to parse chunk size")
391383

392384
##################
393385
# Internal Reading

test/bandit/http1/protocol_test.exs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,25 @@ defmodule HTTP1ProtocolTest do
13501350
assert msg == "** (Bandit.HTTPError) Unable to parse chunk size"
13511351
end
13521352

1353+
# RFC9112§7.1.1 chunk extensions come after the chunk-size, not in place of it. A line
1354+
# whose size portion is empty once extensions are split off (chunk-ext starting at the very
1355+
# first byte) is still required to satisfy chunk-size = 1*HEXDIG.
1356+
@tag :capture_log
1357+
test "rejects a chunk size that is empty once extensions are split off", context do
1358+
client = SimpleHTTP1Client.tcp_client(context)
1359+
1360+
SimpleHTTP1Client.send(client, "POST", "/expect_chunked_body", [
1361+
"Host: localhost",
1362+
"Transfer-encoding: chunked"
1363+
])
1364+
1365+
Transport.send(client, ";ext=value\r\n")
1366+
assert SimpleHTTP1Client.recv_reply(client) ~> {:ok, "400 Bad Request", list(), ""}
1367+
1368+
assert_receive {:log, %{level: :error, msg: {:string, msg}}}, 500
1369+
assert msg == "** (Bandit.HTTPError) Unable to parse chunk size"
1370+
end
1371+
13531372
# RFC9112§6.1 combines repeated transfer-encoding headers into one
13541373
# comma-separated value; RFC9112§6.3 then requires rejecting the request,
13551374
# since chunked is no longer the final encoding.

0 commit comments

Comments
 (0)