Skip to content

fetch: reject a stream chunk that is not bytes or a string in blob() and formData() - #45005

Open
robobun wants to merge 1 commit into
mainfrom
robobun/e40b95f6/body-blob-strict-chunks
Open

robobun wants to merge 1 commit into
mainfrom
robobun/e40b95f6/body-blob-strict-chunks

Conversation

@robobun

@robobun robobun commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • blob() and formData() of Response/Request convert any chunk of a JS stream body to a string: 5 then {} resolves "5[object Object]". The other four body methods refuse it. No user reported this.
  • Cause: readableStreamToBlob gives the chunk array to new Blob(chunks) (BunStreamConsumers.cpp:1510).

Fix

  • The two Body entry points pass BlobChunkPolicy::BytesOrString. The part loop of new Blob() (Blob.rs) then throws TypeError: Expected an ArrayBuffer, ArrayBufferView, or string chunk. For a direct stream, the Array sink's write() throws.
  • Bun.readableStreamToBlob, stream.blob(), Bun.readableStreamToFormData and node:stream/consumers blob() keep new Blob(chunks), as bun-types documents.
  • Verified: test/js/web/fetch/body.test.ts (78 new tests, 58 fail on main), streams.test.js, async-iterator-stream.test.ts, macro-test.test.ts.
  • Self-reviewed: 26 concerns raised, 20 addressed. Not done: the rule in the shared chunk collector (changes four more methods), a copy in write() (older defect), a debug guard.

Background

  • Fetch reads a body with the Streams "read all bytes" steps: a chunk that is not a Uint8Array is a TypeError. Bun also takes strings.
  • new Blob(parts) converts a part that is not a BufferSource or a Blob with ToString.
  • A direct stream gives each chunk to write() of a sink. blob() uses an Array sink.
  • Considered a check before new Blob(chunks): one more pass, and no rejection in write().

Downsides

  • A Blob, number or object chunk now rejects in a body's blob() and formData(). A Blob chunk gave correct bytes before.
  • Bun.readableStreamToBlob: +3 instructions per chunk (499 to 502). .text: +1,792 B.
  • A bad chunk on a stream that never closes still leaves all six methods pending.
Notes

Scope

  • There is no issue and no user report. A read of the code found this. The behaviour dates from 2022.
  • The change makes the six body methods agree. It is not a WPT gain: fetch/api/response/response-stream-bad-chunk.any.js stays at 1 of 6, because its bad chunk is a string and Bun takes a string chunk in every consumer on purpose.
  • The spec rule and the Node comparison apply to a ReadableStream body. Node v26.3.0 rejects 5 and {} there in all six methods.
  • An async-iterable body is a Bun extension (it is a direct stream inside). Node coerces each value with Buffer.from there: an array of bytes and a String object resolve. Bun keeps string, ArrayBuffer and ArrayBufferView, as text() already does.
  • A macro that returns new Response(stream) with such a chunk now fails the build. It inlined the string form before (5 became "data:;base64,NQ==").

Not changed by this PR

  • bytes() and arrayBuffer() throw synchronously, and do not reject, for a bad chunk on a stream that was filled and closed before the read.
  • A default-controller stream is checked when it closes, in all six methods. Node rejects at the chunk.
  • response.body.blob() is ReadableStream.prototype.blob(). It keeps new Blob(chunks).
  • textStream() takes BufferSource chunks only, and errors and cancels at the chunk.
  • The new rejection on a default-controller body has no stack. The error is created in a native promise reaction, as for text() and bytes() on a stream that is still open.
  • A String object chunk on a direct or async-iterable body: blob() and formData() now reject it, as text() does. bytes() and arrayBuffer() still take it through the native ArrayBufferSink.
  • A direct or async-iterable chunk is still read when the body closes, in text(), blob() and formData(). A producer that reuses one buffer gets wrong bytes, on main and on this branch.
  • stack in the part loop of new Blob() is never pushed to, so its reserve is one allocation per multi-part array.

One visible edge

  • On a body's Array sink, write() now returns the size of the chunk as the Text sink does. An empty ArrayBuffer or DataView returns 0. It returned undefined.

Related open PRs

Measurements
Release builds on linux x64, main 6c1eb06 against f3c6d6f. An instruction count is a ptrace single-step of the JS thread from entry to return of the named function. perf, valgrind and strace are not installed on this machine.

  • Response.blob() reaction, binary 1KiB x8192: 498.7 -> 405.7 instructions per chunk, 17 -> 17 mallocs per call. With 16 B chunks: 386.1 -> 293.1 per chunk. String 64KiB x128: 373,020 -> 360,479 per call.
  • Bun.readableStreamToBlob: +4 instructions per call, +3 per chunk (498.8 -> 501.8).
  • controller.write() on the Array sink: body 695.5 -> 155.5 (string) and 770.5 -> 172.5 (Uint8Array) instructions per write. Stream helpers +4. Text sink +1 to +2. ArrayBuffer sink 650.6 -> 649.6 and 304.2 -> 304.2. The sink dispatch has no indirect jump on either build (objdump).
  • new Blob([u8,u8,u8]): 2,694 -> 2,708 instructions, 4 -> 4 mallocs per call. new File([u8],'f'): 599 -> 603. new Response([u8,'x']): 2,093 -> 2,093. The part loop instances are 5,806 B and 6,682 B -> 5,825 B and 6,666 B. The instance count is unchanged.
  • Release binary: .text +1,792 B (size -A). The stripped file size is unchanged. +1 reaction handler (98 -> 99). JSStreamsRuntime 936 -> 944 B per global. JSDirectStreamController 176 -> 176 B.
  • GC cells per Response.blob() on a buffered JS stream: Promise 4 -> 4, Blob 1 -> 1. Settle tick: blob 1 -> 1, formData 2 -> 2.
  • Peak RSS for one 512 MiB Response.blob(): 1,065 MiB -> 1,065 MiB. One 8 MiB chunk keeps its memfd store.
  • toString calls per Response.blob() on a non-byte chunk: 1 -> 0. Async-iterable yields that run after a refused chunk: 2 -> 0. Lenient entries changed: 0 of 4.
  • Chunks that a rejected blob() on a direct body keeps alive while the promise and the stream are referenced: 8/8 -> 2/8. After they are dropped: 0.
  • Wall clock, bench/snippets/webstreams-consumers.mjs, median of 40 interleaved passes, ms per 8 MiB. Response.blob on a direct stream of 1KiB x8192: 3.22 -> 2.40. On an async generator of 1KiB x8192: 8.03 -> 6.24. Binary 1KiB x8192: 5.55 -> 5.37. toBlob binary 1KiB x8192: 5.46 -> 5.39. The other default-stream rows move -3.2% to +2.7% with an A/A spread up to 3.1%. The untouched Response.arrayBuffer column moves -1.6% to +2.8%.

Self-review items that are not done

  • The chunk rule in readableStreamIntoArray, one check for all six methods with a rejection at the chunk. It changes when text(), json(), bytes() and arrayBuffer() reject, so it is a separate change.
  • A copy in write() for the Text and Array direct sinks. The defect is older, the output is the same on main, and a copy costs about 2x from 1 KiB chunks.
  • A debug-only guard that forbids VM entry while the part loop borrows a buffer. It is a new guard that other code would share.
  • An async stack on consumer rejections, the synchronous throw of bytes(), and the WPT file with its known failures.
  • No tracking issue is filed for these items.

Suites run on the debug build
body.test.ts, streams.test.js, async-iterator-stream.test.ts, macro-test.test.ts, body-stream.test.ts, body-clone.test.ts, body-async-iterator.test.ts, body-mixin-errors.test.ts, blob.test.ts, blob-array-fast-path.test.ts, stream-fast-path.test.ts, utf8-bom.test.ts, FormData.test.ts, direct-readable-stream.test.tsx, serve-direct-readable-stream.test.ts, test-stream-consumers.js. The new tests also pass with BUN_JSC_validateExceptionChecks=1.

Two tests of body.test.ts ("string body consumption does not leak" with json) time out at 5 s on the debug build of this machine. They time out the same way on main.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/streams/streams.test.js

…and formData()

`Response` and `Request` built the result of blob() and formData() for a
JS stream body with `new Blob(chunks)`. That converts any value to a
string, so a number or an object chunk ended up in the result as "5" or
"[object Object]", and a Blob chunk was copied in. text(), json(),
bytes() and arrayBuffer() refuse the same chunk with a TypeError.

The two Body entry points now pass `BlobChunkPolicy::BytesOrString`.
The Blob part walker takes the policy: it throws the TypeError of
bytes() for a part that is not a string, an ArrayBuffer or an
ArrayBufferView. On a direct stream and an async-iterable body the Array
sink's write() throws for such a value, with the error of the byte sink.

`Bun.readableStreamToBlob`, `ReadableStream.prototype.blob`,
`Bun.readableStreamToFormData` and blob() of node:stream/consumers keep
`new Blob(chunks)`.
@robobun

robobun commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:14 PM PT - Oct 11th, 2026

✅ @robobun, your commit f3c6d6f9b459078d6472d1b99f364e0f3cefe0dd passed in Build #124672! 🎉


🧪   To try this PR locally:

bunx bun-pr 45005

That installs a local version of the PR into your bun-45005 executable, so you can run:

bun-45005 --bun

@robobun

robobun commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3 and on main (6c1eb067e3):

const body = value => new ReadableStream({ start(c) { c.enqueue(value); c.close(); } });
await (await new Response(body(5)).blob()).text();        // "5"
await (await new Response(body({ a: 1 })).blob()).text(); // "[object Object]"
await new Response(body(5)).text();                       // TypeError

On this branch, blob() and formData() reject the same body with a TypeError. The new tests in test/js/web/fetch/body.test.ts fail on main and pass on this branch.

PR: #45005

@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 756a67ec-5113-4998-9681-187c1af19f5c


📥 Commits

Reviewing files that changed from the base of the PR and between 7c96a12 and f3c6d6f.



📒 Files selected for processing (16)
  • bench/snippets/webstreams-consumers.mjs
  • src/jsc/JSGlobalObject.rs
  • src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp
  • src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp
  • src/jsc/bindings/webcore/streams/JSDirectStreamController.h
  • src/jsc/bindings/webcore/streams/JSReadableStream.cpp
  • src/jsc/bindings/webcore/streams/JSStreamsRuntime.h
  • src/jsc/bindings/webcore/streams/StreamsForward.h
  • src/jsc/bindings/webcore/streams/WebStreamsExports.cpp
  • src/jsc/bindings/webcore/streams/WebStreamsInternals.h
  • src/runtime/webcore/Blob.rs
  • test/bundler/transpiler/macro-test.test.ts
  • test/js/bun/http/async-iterator-stream.test.ts
  • test/js/web/fetch/body.test.ts
  • test/js/web/streams/direct-stream-contract.ts
  • test/js/web/streams/streams.test.js


Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.




Walkthrough

Response and Request body consumers now validate stream chunks as strings or BufferSources, while Blob-part APIs retain Blob conversion behavior. The change adds policy-aware direct-stream writes and body-Blob conversion, with regression tests and benchmarks for stream shapes and consumers.

Changes

Stream Body Chunk Validation

Layer / File(s) Summary
Blob conversion policies
src/jsc/bindings/webcore/streams/StreamsForward.h, src/runtime/webcore/Blob.rs, src/jsc/JSGlobalObject.rs
Blob-part conversion retains its existing behavior. Strict stream conversion accepts primitive strings and BufferSources, validates items before conversion, and finalizes the resulting Blob.
Body consumer validation and conversion
src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp, src/jsc/bindings/webcore/streams/JSDirectStreamController.*, src/jsc/bindings/webcore/streams/JSReadableStream.cpp, src/jsc/bindings/webcore/streams/JSStreamsRuntime.h, src/jsc/bindings/webcore/streams/WebStreams*.{cpp,h}
Response and Request body consumers pass the strict policy through stream conversion. Direct-stream array sinks validate writes, and the body-Blob handler converts collected chunks with strict Blob conversion.
Consumer tests and benchmarks
test/js/web/fetch/body.test.ts, test/js/web/streams/*, test/js/bun/http/async-iterator-stream.test.ts, test/bundler/transpiler/macro-test.test.ts, bench/snippets/webstreams-consumers.mjs
Tests cover rejection and accepted chunk types across body consumers, Blob-part behavior, direct-stream writes, macro responses, and async-iterable bodies. The benchmark adds direct binary and async-generator inputs and a Response.blob consumer.

Suggested reviewers: dylan-conway, jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to f3c6d

The change is mergeable after normal checks; the investigated direct-stream path applies validation before chunks are written.

Pre-merge checks | Passed 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly identifies the main change: rejecting non-byte, non-string stream chunks in fetch body blob() and formData() consumption.
Description check Passed The description provides detailed problem, fix, verification, scope, behavior, performance, and known limitations information. It does not use the template headings exactly, but it contains the requir…

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@claude claude 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.

I reviewed this PR and didn't find any bugs. Because it changes user-visible fetch body semantics (a Blob/File chunk in a Response/Request stream body now rejects where it previously produced the right bytes) and adds a new Rust/C++ FFI entry into the Blob part walker, a human look at the behavior decision would still be worthwhile.

What was reviewed:

  • Blob__fromStreamChunks / PartPolicy::BytesOrString in Blob.rs: the strict walk rejects before converting any part that could run user JS, so skipping the prescan keeps the detach_lifetime borrow sound; the heap Blob's +1 ref is handed to the JS wrapper once via BlobExt::to_js.
  • onReadableStreamToBodyBlobFulfilled and the strict Array sink write(): exception checks follow every throwing call, and the lenient Bun.readableStreamToBlob / stream.blob() / node:stream/consumers paths are unchanged (covered by the new streams.test.js cases).
  • Candidate concerns ruled out: write() now returning a number instead of undefined on a body's Array sink is intentional and matches the Text sink; chunks retained after a rejected direct-stream blob() are dropped once the promise/stream are unreferenced.
Extended reasoning...

The change threads a BlobChunkPolicy through readableStreamToBlob/FormData and the direct-stream Array sink so that Response/Request blob() and formData() reject non-byte, non-string chunks like the other four body methods, via a new Blob__fromStreamChunks FFI into Blob.rs's part walker with a strict PartPolicy. It touches no auth, crypto, or injection surface, but it does alter observable Web API behavior (Blob/File chunks in a stream body now reject) and modifies an unsafe lifetime argument in the Blob part loop. The source diff is about 200 lines with roughly 400 lines of new matrix tests across body.test.ts, streams.test.js, async-iterator-stream.test.ts and macro-test.test.ts; the bug hunt ran dry with no findings. The deliberate compat tradeoff (rejecting Blob chunks that previously worked) and the bot authorship are what tip this to defer rather than approve.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant