Read the chunk-size line strictly in poll-chunked - #28
Conversation
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.
There was a problem hiding this comment.
Build & Tests
bash test/run.shon9f25ef6, which is what CI runs: 179 passed, 0 failed. It ran with a private output directory andCARP_DIRset to the local core plus carp-lang #1587. CI is green on both legs.parse-size-lineandbws?are byte-identical to http 0.7.0'sTransferEncodingversions. Only thehidden/privateorder differs, and it matches this file's own order.- Teeth, re-run here with each mutant applied to
http-client.carpalone. 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, onmainand 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'shttp.client, all on this machine. - The Go column comes from reading
src/net/http/internal/chunked.goon 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.getfail withmalformed chunked body: invalid chunk size '5 '. - With a padded last chunk (
0SP after a complete body), the caller loses a body that arrived in full. maindid 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
0and 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 `.
|
Follow-up in 54fa338 for carpentry-reviewer's review of 9f25ef6. Finding 1 (trailing whitespace). I applied the suggested change: in
Finding 2 (stray CR). A new Verification. I ran
Parity with http. The client is now deliberately one notch looser than http's server-side |
There was a problem hiding this comment.
Build & Tests
- On
54fa338,bash test/run.sh, which is what CI runs: 181 passed, 0 failed. It ran with acarpwrapper whose only change is a private output directory, and withCARP_DIRset 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:
- Trailing BWS. In
parse-size-line,(= digits len)→(= i len)is exactly the suggested change./chunked-trailing-spacenow expects200 [hello], and the new/chunked-zero-trailing-spacepins a padded0last chunk ending the body. - 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.
ResponseStream.poll-chunkedtrimmed the chunk-size line before parsing it. That meant the client accepted5,5with no extension after it,\t5, and0as the last chunk. Since carpentry-org/http#42 merged,TransferEncoding.dechunkreads 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-lineandbws?toResponseStream, next toparse-hex. Both are copied unchanged fromhttp'sTransferEncoding, andpoll-chunkednow callsparse-size-lineinstead of trimming. Overflow is still caught by the existingparse-hex.The error message keeps the format
malformed chunked body: invalid chunk size '%s', but it now quotes the whole size line, asdechunkdoes. Quoting the trimmed size would report5asinvalid 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 intest/http-client.carp:5\r\nhello\r\n0\r\n\r\n,5 \r\n…,\t5\r\n…, and a0last chunk after a good chunk5 ;nameand5\t;nameThe existing
/chunked-extroute (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 acarpwrapper onPATHwhose only change is a privateoutput-directory. Result: 179 passed, 0 failed.http-client.carp(trim and all) and kept the new tests. The four rejection assertions fail, each getting200 [hello](175 passed, 4 failed). A second mutant, with the BWS skip removed fromparse-size-line, fails exactly the two still-decoded assertions (177 passed, 2 failed).carp-fmt --checkandangler, both built from their currentmain, pass on the changed.carpfiles.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.