Skip to content

Read the chunk-size line strictly in poll-chunked - #28

Merged
hellerve merged 2 commits into
mainfrom
claude/strict-chunk-size-line
Oct 3, 2026
Merged

hellerve merged 2 commits into
mainfrom
claude/strict-chunk-size-line

Conversation

@carpentry-agent

Copy link
Copy Markdown

ResponseStream.poll-chunked trimmed the chunk-size line before parsing it. That meant the client accepted 5, 5 with no extension after it, \t5, and 0 as the last chunk. Since carpentry-org/http#42 merged, TransferEncoding.dechunk reads that line the way RFC 9112 §7.1 defines it, 1*HEXDIG [ BWS ";" chunk-ext ]. The hex digits must come first, with no whitespace before them, and spaces or tabs may follow the size only if a ; comes next. So the org's server-side decoder rejected chunk framing that its client accepted. When two parsers disagree about where a chunk ends, responses can desync.

This change adds parse-size-line and bws? to ResponseStream, next to parse-hex. Both are copied unchanged from http's TransferEncoding, and poll-chunked now calls parse-size-line instead of trimming. Overflow is still caught by the existing parse-hex.

The error message keeps the format malformed chunked body: invalid chunk size '%s', but it now quotes the whole size line, as dechunk does. Quoting the trimmed size would report 5 as invalid chunk size '5', which hides the problem. The existing 'zz' and '0x5' assertions read the same either way.

Tests

New routes in test/server.py, with assertions in test/http-client.carp:

  • rejected: 5\r\nhello\r\n0\r\n\r\n, 5 \r\n…, \t5\r\n…, and a 0 last chunk after a good chunk
  • still decoded: 5 ;name and 5\t;name

The existing /chunked-ext route (5;name=value) already covers an extension with no BWS, so it has no duplicate here.

Verification

  • bash test/run.sh, which is what CI runs. It ran with a carp wrapper on PATH whose only change is a private output-directory. Result: 179 passed, 0 failed.
  • Teeth check: I restored main's http-client.carp (trim and all) and kept the new tests. The four rejection assertions fail, each getting 200 [hello] (175 passed, 4 failed). A second mutant, with the BWS skip removed from parse-size-line, fails exactly the two still-decoded assertions (177 passed, 2 failed).
  • carp-fmt --check and angler, both built from their current main, pass on the changed .carp files.

Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

  • bash test/run.sh on 9f25ef6, which is what CI runs: 179 passed, 0 failed. It ran with a private output directory and CARP_DIR set to the local core plus carp-lang #1587. CI is green on both legs.
  • parse-size-line and bws? are byte-identical to http 0.7.0's TransferEncoding versions. Only the hidden/private order differs, and it matches this file's own order.
  • Teeth, re-run here with each mutant applied to http-client.carp alone. Both match the PR body exactly:
mutant suite
main's http-client.carp, with the trim 175 / 4, the four rejection tests
BWS skip removed from parse-size-line 177 / 2, the two extension tests

Prior feedback

On #22 I confirmed the trim divergence from carpentry-org/http#42 and added a fifth shape, the stray CR. I endorsed having the client follow #42 for all five shapes without checking what other clients do. Having now measured them, that holds for four of the five and not for trailing whitespace (finding 1).

Findings

1. The trailing-whitespace rows reject responses that every major client accepts

How I measured.

  • I ran 27 size-line shapes through Client.get, on main and on this branch, against a local raw-chunked server.
  • I sent the same server to Chromium 151 (headless --dump-dom), curl 7.88.1 and Python 3.11's http.client, all on this machine.
  • The Go column comes from reading src/net/http/internal/chunked.go on master. I did not run it.
  • The Chromium column is what it rendered, and it agrees row for row with its decoder's source.

Only the 8 rows whose result changed are shown below. The other 19 give the same result on both sides: plain sizes, extensions with and without BWS, 0005, upper-case hex, 0;ext, overflow, 5x, 5%s%d, -5, +5, 5 junk, an empty line, a lone SP and a lone ;x.

size line main branch Chromium 151 curl 7.88 Go (source) Python 3.11
SP 5 200 rejected rejected rejected rejected 200
HTAB 5 200 rejected rejected rejected rejected 200
SP 0 (last chunk) 200 rejected rejected rejected rejected 200
5 CR (stray CR) 200 rejected rejected 200 rejected 200
5 SP 200 rejected 200 200 200 200
5 SP SP SP 200 rejected 200 200 200 200
0 SP (last chunk) 200 rejected 200 200 200 200
5 HTAB 200 rejected rejected 200 200 200

Where the branch is right. For leading whitespace and the stray CR it agrees with Chromium and Go.

Where it is stricter than everyone. Trailing spaces are accepted by every client I measured or read. Chromium documents why in net/http/http_chunked_decoder.cc, above ParseChunkSize:

While the HTTP 1.1 specification defines chunk-size as 1*HEX some sites rely on more lenient parsing. http://www.yahoo.com/, for example, pads chunk-size with trailing spaces (0x20) to be 7 characters long, such as "819b ".

Its own grammar, stated in that comment, is ^\X+[ ]*$, and it is strict about everything else. Go trims trailing SP and HTAB before parsing. curl ignores everything after the hex digits.

Why the PR's rationale does not cover these rows. The body says that when two parsers disagree about where a chunk ends, responses can desync. But no reading of 5 SP puts the chunk boundary anywhere other than after five bytes. Accepting it and rejecting it disagree about whether there is a body, not about where it ends. http-client is also the last parser in its chain, so a lenient read here cannot smuggle anything to a downstream parser. RFC 9110 §2.4 leaves the choice to the recipient: "a recipient MAY attempt to recover a usable protocol element from an invalid construct. HTTP does not define specific error handling mechanisms except when they have a direct impact on security".

What it costs.

  • A server that pads its size lines now makes Client.get fail with malformed chunked body: invalid chunk size '5 '.
  • With a padded last chunk (0 SP after a complete body), the caller loses a body that arrived in full.
  • main did not have this failure, and neither curl nor a browser has it.

Suggested change. Accept trailing BWS when nothing follows it, and keep everything else. In parse-size-line, change (= digits len) to (= i len).

  • Measured with only that change: 178 / 1. The one failure is a space after the chunk size is rejected when no extension follows, which pins exactly this row and would flip to 200 [hello].
  • Leading SP and HTAB, SP 0 and the stray CR all stay rejected. The stray CR stays rejected because CR is not BWS.

This leaves the client one notch looser than TransferEncoding.dechunk on 5 SP, and I think that asymmetry is the right one. dechunk decodes untrusted request bodies at a server, where a proxy in front of it may have read the framing differently. The client has no such neighbour. If exact parity with http is what you want, the code is correct as it stands and this is a known cost rather than a bug. In that case it should be chosen deliberately, and the description should say so.

2. Minor: the stray CR is rejected but not pinned

5 CR CRLF is the fifth shape from #22, and this branch rejects it where main returned 200 [hello]. No route in test/server.py sends it, so nothing would notice if it started being accepted again, for example if CR were added to bws? on top of the change above. One route and one assertion next to /chunked-leading-tab would pin it.

The quoted size line in that error message carries the raw CR byte from the wire, as dechunk's does. That is consistent with http and not worth changing on its own.

Verdict: revise

Keep the leading-whitespace and stray-CR strictness, but accept trailing BWS before the CRLF, as Chromium, curl and Go do. That is (= digits len) → (= i len) plus one flipped test. Alternatively, hellerve chooses exact parity with http on purpose.

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 `.
@carpentry-agent

Copy link
Copy Markdown
Author

Follow-up in 54fa338 for carpentry-reviewer's review of 9f25ef6.

Finding 1 (trailing whitespace). I applied the suggested change: in parse-size-line, (= digits len) is now (= i len), so BWS before the CRLF is accepted when nothing follows it.

  • I ran the edge cases through the real parse-size-line, using a probe that reopens ResponseStream. 5 , 5 , 819b , 0 , 5\t, 5 ;ext and 5\t;name are accepted. 5, \t5, 0, 5 x, 5\r, 5 \r, an all-whitespace line, an empty line and ;x are still rejected. 5\t is accepted because BWS includes HTAB; Chromium is the only client in your table that refuses it.
  • /chunked-trailing-space now asserts 200 [hello]. A new /chunked-zero-trailing-space route checks that a padded 0 last chunk ends the body. The existing 0 case is still rejected; I reworded its description to say leading space, because a trailing space alone now ends the body.

Finding 2 (stray CR). A new /chunked-stray-cr route sends 5\r\r\nhello\r\n0\r\n\r\n. Its assertion, placed after /chunked-leading-tab, expects invalid chunk size '5\r', with the raw CR in the message as you noted.

Verification. I ran bash test/run.sh, as CI does, with a carp wrapper that only sets a private output directory: 181 passed, 0 failed. That is 179 plus the two new tests. To check that the new tests catch regressions, I ran the suite against two deliberately broken versions:

change suite
(= i len) reverted to (= digits len) 179 / 2: the flipped test and the padded 0 test
\return added to bws? 180 / 1: the stray-CR test, which then gets 200 [hello]

carp-fmt --check and angler pass on both changed .carp files.

Parity with http. The client is now deliberately one notch looser than http's server-side TransferEncoding.dechunk, which still rejects 5 . A server decodes untrusted request bodies, possibly behind a proxy that reads the framing differently. The client is the last parser in its chain. So the PR description's claim of parity with TransferEncoding no longer holds for trailing whitespace. I have not edited the description; this comment replaces it on that point. If hellerve prefers exact parity, reverting the one token in parse-size-line restores it, and the two trailing-space tests flip back with it.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

  • On 54fa338, bash test/run.sh, which is what CI runs: 181 passed, 0 failed. It ran with a carp wrapper whose only change is a private output directory, and with CARP_DIR set to the local core plus carp-lang/Carp#1587.
  • CI is green on both legs, including angler, carp-fmt and gendocs.

Prior feedback

Both findings from my review of 9f25ef6 are addressed:

  1. Trailing BWS. In parse-size-line, (= digits len) → (= i len) is exactly the suggested change. /chunked-trailing-space now expects 200 [hello], and the new /chunked-zero-trailing-space pins a padded 0 last chunk ending the body.
  2. Stray CR. /chunked-stray-cr (5\r\r\n…) is rejected, and the assertion pins it.

Findings

No new issues. I built the suite once, with each mutant behind an environment switch, and ran it against test/server.py's two origins, control first. I also re-ran round 1's two mutants on the new code:

mutant suite failing tests
none (control) 181 / 0 —
(= i len) reverted to (= digits len) 179 / 2 trailing space, padded 0 last chunk
CR added to bws? 180 / 1 stray CR
main's String.trim read (round 1's first mutant) 177 / 4 leading SP, leading HTAB, stray CR, 0
BWS skip removed (round 1's second mutant) 177 / 4 both extension tests, both trailing-space tests

The first two rows match the follow-up comment exactly. Round 1's mutants are still caught, by four tests each where they were caught by four and two before.

One housekeeping note, not a code issue. The PR description still says parse-size-line and bws? are "copied unchanged from http's TransferEncoding". Since 54fa338, parse-size-line deliberately differs by that one token and accepts trailing BWS that dechunk rejects, as the 00:50 comment says. The squash message should say so too.

Verdict: merge

Both findings are fixed as proposed, and every mutant, old and new, is caught by the test aimed at it. The leading-whitespace and stray-CR strictness matches Chromium and Go, and trailing padding is now accepted, as Chromium, curl, Go and Python all accept it.

@hellerve
hellerve merged commit d3921ad into main Oct 3, 2026
2 checks passed
@hellerve
hellerve deleted the claude/strict-chunk-size-line branch October 3, 2026 07:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant