Repository navigation
fix(node): reject overlapping body reads - #161
Conversation
`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
@standard-server/aws-lambda
@standard-server/core
@standard-server/fastify
@standard-server/fetch
@standard-server/node
@standard-server/peer
@standard-server/shared
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
toStandardBodysingle-read guard —packages/node/src/body.tsadds a module-localWeakSet<NodeHttpRequest>and, after thehint === 'none'early return, throws the existingTypeErrorwhen!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-parsedreq.bodypath stay re-readable.- Node regression tests —
packages/node/src/body.test.tsadds overlapping-read cases forjson/file/url-search-params, second reads before anoctet-stream/event-streamis drained, and ahint: 'none'non-marking case. - Fastify fallback test —
packages/fastify/src/request.test.tscovers two simultaneousresolveBody('json')calls through the catch-all content-type parser (no adapter change needed; it passes the samereq.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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Problem
toStandardBodyin@standard-server/nodeonly checked!req.readableto 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 toreq. A second read got past the check in two cases:none)octet-stream/event-streamread whose stream had not been drainedNode's iterator gives all buffered data to the first consumer, so the second one silently got
undefinedJSON, a 0-byteFile, an emptyURLSearchParams, an emptyReadableStreamor 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 aTypeError.Repro: a
node:httphandler runningPromise.allSettled([r.resolveBody(), r.resolveBody()])on a{"amount":100}JSON POST resolved to[fulfilled {amount:100}, fulfilled undefined].Fix
WeakSet<NodeHttpRequest>inpackages/node/src/body.ts.hint === 'none'early return,toStandardBodythrows the existingTypeError('Failed to read body: body stream already read or destroyed')when!req.readable || consumedRequests.has(req), and otherwise marks the request.await, so they catch overlapping reads and reads after a lazy read.toStandardBody,toStandardLazyRequest, and the@standard-server/fastifyfallback parser, which needs no code change.What doesn't change:
hint: 'none'still returnsundefinedand doesn't mark the request.req.bodypath for bodies a framework already parsed (e.g. Express) is untouched.Tests
packages/node/src/body.test.ts(underedge case):json,fileandurl-search-paramshints: the first read gets the full body, the second rejectsoctet-streamis drained rejects, and the stream still yields the full bodyevent-streamis drained rejects, and the iterator still yields every eventhint: 'none'call doesn't block a real read afterwardspackages/fastify/src/request.test.ts: two simultaneousresolveBody()calls through the catch-all content-type parser; the second rejects.All 6 rejection tests fail on the previous
body.tsand pass with the fix. The fullvitest runpasses (1364 tests, including the roottests/integration suites), andpnpm run checkis clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01Se7vZ6pehYANTk37V7MB2V
Generated by Claude Code