Skip to content

fix(http2,http3): reconcile a streamed body against its declared Content-Length - #348

Merged
cryo2010 merged 1 commit into
mainfrom
fix/h2-h3-declared-body-length
Sep 28, 2026
Merged

cryo2010 merged 1 commit into
mainfrom
fix/h2-h3-declared-body-length

Conversation

@cryo2010

Copy link
Copy Markdown
Owner

Closes #345.

request.nim reconciled respContentLength against respBodyWritten only on the HTTP/1 path (#248), forcing closeAfterFlush on a mismatch so the peer at least sees an incomplete read. The h2 and h3 branches of sendHead / write / finish returned before that accounting, so a streamed body that ended short of the Content-Length its head already declared left as a well-formed END_STREAM (or FIN): a truncation only the client could notice, and only as a protocol error of ours (the client's h2 stack raised InvalidBodyLengthError), while the server logged nothing and went on serving. That is how the 2026-09-26 stress soak's truncated static-file read (d14f557, on fix/stress-all-round1) reached the client as a complete 1 GiB download.

The guard

H2Stream and H3Stream each gain respDeclaredLen / respBodyWritten:

  • h2SendHead / h3SendHead take the length the caller already put in the head (remembered, not re-encoded) and zero the counter;
  • h2StreamWrite / h3StreamWrite count every chunk they accept;
  • h2StreamFinish / h3StreamFinish compare the two, and a body short of (or past) its declared length is RESET, RST_STREAM / RESET_STREAM with INTERNAL_ERROR, instead of terminated cleanly at a length the head contradicts.

Compression already drops the declared length (the compressed size is unknown), so a compressed stream is never reconciled. HEAD, which declares a length and writes no body by design, stays exempt: its stream closes at the head, before finish can reconcile anything. h2StreamAbort / h3StreamAbort moved above finish so the mismatch path reuses the existing abort rather than repeating its teardown.

This is the general net d14f557 asked for: that commit fixed the file streamer's own accounting, this one catches any future short streamed body on h2/h3, from any producer.

Tests

Each new test was verified to fail with the guard removed:

  • tests/test_http2_download.nim: a short body and an over-long body each end in RST_STREAM(INTERNAL_ERROR) with no END_STREAM; an exact-length body still completes with END_STREAM; HEAD with a declared length is not reset. fetchBody now records the RST error code and a HEADERS-borne END_STREAM, and takes a request verb.
  • tests/test_http3.nim: a short body makes curl report the stream reset (the diagnostic is asserted, not just a non-zero exit, since the short read alone already failed the transfer before the fix); an exact-length body completes.

Full local suite: 78 suites, no failures (the usual compression suites skip without -d:httpGzip / -d:httpBrotli / -d:httpZstd).

Also documents the behavior in the README's Download section and adds a CHANGELOG entry under Unreleased/Fixed.

…ent-Length

`request.nim` reconciled `respContentLength` against `respBodyWritten` only on
the HTTP/1 path (#248), forcing `closeAfterFlush` on a mismatch so the peer at
least sees an incomplete read. The h2 and h3 branches of sendHead / write /
finish returned before that accounting, so a streamed body that ended short of
the Content-Length its head already declared left as a well-formed END_STREAM
(or FIN): a truncation only the client could notice, which it can report only as
a protocol error of ours (h2's InvalidBodyLengthError), while the server logged
nothing and went on serving. That is how the 2026-09-26 stress soak's truncated
static-file read (d14f557) reached the client as a complete 1 GiB download.

Give both codecs the guard HTTP/1 has. H2Stream and H3Stream each gain
respDeclaredLen / respBodyWritten: h2SendHead / h3SendHead take the length the
caller already put in the head (remembered, not re-encoded) and zero the
counter, h2StreamWrite / h3StreamWrite count every chunk they accept, and
h2StreamFinish / h3StreamFinish compare the two. A body short of, or past, its
declared length is now RESET (RST_STREAM / RESET_STREAM, INTERNAL_ERROR) instead
of terminated cleanly at a length the head contradicts, so the cut-short
transfer is visible as the server's error. Compression already drops the
declared length (the compressed size is unknown), and HEAD -- which declares a
length and writes no body by design -- stays exempt: its stream closes at the
head, before finish can reconcile anything.

h2StreamAbort / h3StreamAbort move above finish so the mismatch path reuses the
existing abort rather than repeating its teardown.

The tests pin the fix in both directions and fail without it: over h2, a short
and an over-long body each end in RST_STREAM(INTERNAL_ERROR) with no END_STREAM,
an exact-length body still completes, and HEAD with a declared length is not
reset; over h3, curl reports the reset (asserting the diagnostic, not just a
non-zero exit, since the short read alone already failed the transfer).
@cryo2010
cryo2010 merged commit 2151a40 into main Sep 28, 2026
35 checks passed
@cryo2010
cryo2010 deleted the fix/h2-h3-declared-body-length branch September 28, 2026 06:55
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.

HTTP/2 and HTTP/3 lack the declared-vs-written body length check that HTTP/1 has

1 participant