Skip to content

fix(node): reject overlapping body reads - #161

Merged
dinwwwh merged 2 commits into
mainfrom
claude/wonderful-euler-lxanw3
Oct 13, 2026
Merged

dinwwwh merged 2 commits into
mainfrom
claude/wonderful-euler-lxanw3

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 13, 2026

Copy link
Copy Markdown
Member

Problem

toStandardBody in @standard-server/node only checked !req.readable to see whether the body had already been read. That flag turns false only after the request emits 'end', and each parser (_streamToString, _streamToFile, toWebReadableStream, toAsyncIteratorObject) attaches its own async iterator to req. A second read got past the check in two cases:

  • it overlapped the first read (any hint except none)
  • it came after an octet-stream / event-stream read whose stream had not been drained

Node's iterator gives all buffered data to the first consumer, so the second one silently got undefined JSON, a 0-byte File, an empty URLSearchParams, an empty ReadableStream or an SSE iterator with no events, with no error. This happened even when the body arrived in a single chunk. The Fetch and peer adapters already reject a second read with a TypeError.

Repro: a node:http handler running Promise.allSettled([r.resolveBody(), r.resolveBody()]) on a {"amount":100} JSON POST resolved to [fulfilled {amount:100}, fulfilled undefined].

Fix

  • Adds a module-level WeakSet<NodeHttpRequest> in packages/node/src/body.ts.
  • After the hint === 'none' early return, toStandardBody throws the existing TypeError('Failed to read body: body stream already read or destroyed') when !req.readable || consumedRequests.has(req), and otherwise marks the request.
  • The check and the marking both happen synchronously, before the first await, so they catch overlapping reads and reads after a lazy read.
  • This covers direct callers of the exported toStandardBody, toStandardLazyRequest, and the @standard-server/fastify fallback parser, which needs no code change.

What doesn't change:

  • hint: 'none' still returns undefined and doesn't mark the request.
  • The req.body path for bodies a framework already parsed (e.g. Express) is untouched.
  • Bodies Fastify parsed itself are unaffected.
  • A read after an earlier read has fully finished still throws, as before.
  • There is no API change and no new option.

Tests

  • packages/node/src/body.test.ts (under edge case):
    • overlapping reads with the json, file and url-search-params hints: the first read gets the full body, the second rejects
    • a second read before an octet-stream is drained rejects, and the stream still yields the full body
    • a second read before an event-stream is drained rejects, and the iterator still yields every event
    • a hint: 'none' call doesn't block a real read afterwards
  • packages/fastify/src/request.test.ts: two simultaneous resolveBody() calls through the catch-all content-type parser; the second rejects.

All 6 rejection tests fail on the previous body.ts and pass with the fix. The full vitest run passes (1364 tests, including the root tests/ integration suites), and pnpm run check is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Se7vZ6pehYANTk37V7MB2V


Generated by Claude Code

`toStandardBody` only guarded on `req.readable`, which turns false once the
stream ends. A second read that overlapped the first, or followed a lazy
(octet-stream / event-stream) read that had not been drained, attached a
second async iterator to the request and silently got an empty body
(`undefined` JSON, a 0-byte File, an empty stream or iterator) instead of an
error.

Track requests a parser has been attached to in a module-level WeakSet and
mark them synchronously, before any parser touches the stream, so a second
read now rejects with the same TypeError as the fetch and peer adapters.
`hint: 'none'` and the upstream-parsed `req.body` path are unchanged, and the
Fastify fallback parser gets the fix for free.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Se7vZ6pehYANTk37V7MB2V
Assert the rejection with a single `toEqual(new TypeError(...))`, which
checks both the error class and the exact message, instead of three
different two-step forms. Read the drained stream with the already imported
`text` helper, drop the redundant mid-read `hint: 'none'` call, and shorten
the guard comment in `toStandardBody`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Se7vZ6pehYANTk37V7MB2V
@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026

Copy link
Copy Markdown
@standard-server/aws-lambda

npm i https://pkg.pr.new/@standard-server/aws-lambda@161

@standard-server/core

npm i https://pkg.pr.new/@standard-server/core@161

@standard-server/fastify

npm i https://pkg.pr.new/@standard-server/fastify@161

@standard-server/fetch

npm i https://pkg.pr.new/@standard-server/fetch@161

@standard-server/node

npm i https://pkg.pr.new/@standard-server/node@161

@standard-server/peer

npm i https://pkg.pr.new/@standard-server/peer@161

@standard-server/shared

npm i https://pkg.pr.new/@standard-server/shared@161

commit: 1857b70

@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 4 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 26 untouched benchmarks
⏩ 108 skipped benchmarks1


Comparing claude/wonderful-euler-lxanw3 (1857b70) with main (1512690)

Open in CodSpeed

Footnotes

  1. 108 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • toStandardBody single-read guard — packages/node/src/body.ts adds a module-local WeakSet<NodeHttpRequest> and, after the hint === 'none' early return, throws the existing TypeError when !req.readable || consumedRequests.has(req) before marking the request. Check and mark are synchronous, so overlapping and lazy (stream/iterator) reads are caught; hint: 'none' and the framework-parsed req.body path stay re-readable.
  • Node regression tests — packages/node/src/body.test.ts adds overlapping-read cases for json/file/url-search-params, second reads before an octet-stream/event-stream is drained, and a hint: 'none' non-marking case.
  • Fastify fallback test — packages/fastify/src/request.test.ts covers two simultaneous resolveBody('json') calls through the catch-all content-type parser (no adapter change needed; it passes the same req.raw).

Verified locally: the two touched suites pass (63 tests), and reverting body.ts to the base makes the three new overlapping cases fail with the exact empty/undefined second read the PR describes, confirming the tests are discriminating. pnpm run check (sherif/oxlint/oxfmt/tsc) is clean. The fix brings Node in line with the existing single-read semantics in the Fetch and peer adapters.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit 637e700 into main Oct 13, 2026
11 checks passed
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.

2 participants