Skip to content

FileReader: copy a chunk that does not fit the pull buffer instead of borrowing it past the read - #38969

Closed
robobun wants to merge 1 commit into
mainfrom
farm/e1b80f94/pipe-reader-eof-chunk-uaf
Closed

FileReader: copy a chunk that does not fit the pull buffer instead of borrowing it past the read#38969
robobun wants to merge 1 commit into
mainfrom
farm/e1b80f94/pipe-reader-eof-chunk-uaf

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/node/test/parallel/test-http-chunk-problem.js is red on main (build 97618, x64-asan): AddressSanitizer: heap-use-after-free ... READ of size 42880 in the process that streams cat's stdout into an HTTP response. Same signature in test/regression/issue/09041.test.ts, spawn-stdin-readable-stream.test.ts ("very large chunked data"), node-stream.test.js ("process.stdin should pipe correctly"); on non-ASAN lanes it shows up as corrupt piped data (shell-pipe-read-fault.test.ts teedIsAllA: false, varying sha1 in the http test).
  • The READ is StreamResult::to_js (src/runtime/webcore/streams.rs:785) copying the value FileReader::on_pull returned; the memory was freed by PosixBufferedReader::read_with_fn (src/io/PipeReader.rs:1336) on the way out of the read that on_pull itself issued (src/runtime/webcore/FileReader.rs:1023). Full report under details.
  • Cause: a node stream pulls again from inside its 'data' handler, which runs inside the outer read loop's on_read_chunk dispatch. Since HTMLRewriter: don't read a streamed input ahead of its reader #38656 only the outermost loop may use the per-loop scratch buffer, so this nested read takes read_with_fn's _buffer path: it collects the bytes in a Vec, delivers them as the EOF chunk, and frees the Vec before returning. FileReader::on_read_chunk, when an EOF chunk did not fit the pull buffer, stored a raw slice of the chunk (ReadDuringJSOnPullResult::Temporary) and on_pull copied it out after the read returned. That was only ever safe because chunks used to come from the scratch buffer; PipeReader: don't re-deliver streamed bytes after a re-entrant read #38726 fixed the double delivery that the same re-routing caused in read_blocking_pipe but did not cover this.
  • Trigger needs a socket with more than 128 KiB queued (so the outer loop flushes before re-arming the poll) and a tail larger than the pull buffer (node streams pull with 64 KiB or less; web-stream readers use 256 KiB, which is why only node-stream shaped consumers hit it), so it depends on producer timing: flaky in CI, deterministic with the test below.

Fix

  • FileReader: the on_pull read state no longer borrows a chunk. A chunk that fits is still copied straight into the pull buffer (Js { buffer, filled }); anything else is copied into FileReader::buffered, together with whatever had already been copied into the pull buffer, and on_pull hands out buffered (UseBuffered). Temporary and the never-produced AmountRead variant are gone, and so is the ret special case for Temporary.
  • Why this is right: on_read_chunk receives memory owned by the reader that is refilled (scratch) or freed/cleared (_buffer paths, both POSIX loops and the Windows reader) as soon as the call returns, so the only thing a consumer may do with a chunk it needs later is copy it. Every other BufferedReaderParent already does (shell, subprocess, filter_run, FileResponseStream via uWS, Terminal, install); FileReader was the one exception. The copy costs nothing extra in practice: Temporary was turned into a fresh Uint8Array by one memcpy, buffered is handed to JS as an owned buffer after one memcpy. The new shape also keeps a second EOF chunk delivered in the same read (HUP drain of a blocking pipe, socket with more than the cutoff queued) instead of dropping it or discarding the bytes already copied into the pull buffer, which the old arm did.
  • Documented the chunk lifetime on BufferedReaderParent::on_read_chunk.
  • Verified: test/js/node/child_process/child_process.test.ts ("child.stdout pull nested in a 'data' event") reproduces the exact ASAN report on unfixed main (READ of size 98304, freed at read_with_fn, allocated at read_with_fn's reserve, from FileReader::on_pull) and passes with the fix; the rest of that file passes (the two tests that time out here also time out without this change, they just spawn many ASAN children; the default-shell test needs $SHELL).
  • bun bd test/js/node/test/parallel/test-http-chunk-problem.js 3/3 green; process-stdin.test.ts, node-stream.test.js, 09041.test.ts (all five runs correct, 78s under ASAN in this container), spawn-stdin-readable-stream.test.ts, html-rewriter.test.js, streams.test.js, spawn.test.ts, spawn-streaming-stdout.test.ts: 628 pass.
  • Onset: HTMLRewriter: don't read a streamed input ahead of its reader #38656 (nested reads moved off the scratch buffer); PipeReader: don't re-deliver streamed bytes after a re-entrant read #38726 is the follow-up that fixed the other half of the cluster. Streams: one PipeReader loop with owned chunks, hold-not-adopt buffer pins, right-sized native pulls #38886 reworks the scratch claim itself and is independent of this; its build 97617 still shows the same UAF.

Background

  • PosixBufferedReader (src/io/PipeReader.rs) reads a pipe/socket and calls its parent's on_read_chunk(chunk) for each piece. Streaming parents implement BufferedReaderParent; FileReader is the parent behind Bun.file().stream(), process.stdin, and spawned stdio streams.
  • Scratch buffer: a 256 KiB per-event-loop buffer the read loops read into so a streaming consumer never needs a heap buffer. When more than half of it is full the loop flushes it to the consumer mid-loop. A read loop that cannot get it (one is already running further up the stack) reads into the reader's own _buffer Vec instead and delivers slices of that Vec.
  • Pull buffer: on_pull(buffer) is the JS side asking for data, passing the typed array to fill. On POSIX on_pull runs the reader synchronously and on_read_chunk reports into read_inside_on_pull; on_pull turns that into the pull result. FileReader::buffered is the reader's own Vec for bytes read while no pull could take them.
  • Nesting: settling a pull promise drains microtasks and nextTicks right there, so node stream internals ('data' handler, then _read) run inside the outer loop's on_read_chunk, and their next pull reads synchronously from there.
ASAN report from the new test on unfixed main
==16399==ERROR: AddressSanitizer: heap-use-after-free on address 0x74030c23f800
READ of size 98304 at 0x74030c23f800 thread T0
    #0 __asan_memcpy
    #4 <bun_runtime::webcore::streams::StreamResult>::to_js src/runtime/webcore/streams.rs:785
    #5 NewSource<FileReader>::process_result src/runtime/webcore/ReadableStream.rs:1320
    #6 NewSource<FileReader>::pull_from_js src/runtime/webcore/ReadableStream.rs:1273

0x74030c23f800 is located 0 bytes inside of 131072-byte region
freed by thread T0 here:
    #13 PosixBufferedReader::read_with_fn src/io/PipeReader.rs:1336
    #14 PosixBufferedReader::read_socket src/io/PipeReader.rs:775
    #15 PosixBufferedReader::read src/io/PipeReader.rs:623
    #16 FileReader::on_pull src/runtime/webcore/FileReader.rs:1023
    #19 NewSource<FileReader>::pull_from_js src/runtime/webcore/ReadableStream.rs:1272

previously allocated by thread T0 here:
    #16 PosixBufferedReader::read_with_fn src/io/PipeReader.rs:1297
    #19 FileReader::on_pull src/runtime/webcore/FileReader.rs:1023

… borrowing it past the read

A read issued from on_pull can be nested inside another read loop's
on_read_chunk dispatch; since the per-loop scratch buffer is only lent to
the outermost loop, such a read collects its bytes in a heap buffer that
PosixBufferedReader::read_with_fn frees right after delivering the final
EOF chunk. FileReader kept a raw slice of that chunk
(ReadDuringJSOnPullResult::Temporary) and copied it out only after the
read returned, from freed memory.

Chunks handed to on_read_chunk are only valid during the call, so the
on_pull state machine now copies: a chunk that fits is still written
straight into the pull buffer, anything else (and whatever was already
written into the pull buffer) goes through FileReader::buffered. This
also covers a second EOF chunk arriving in the same read, which used to
be dropped or to discard the bytes already copied.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e2a9aaf2-8080-43e1-bdd3-9c6cc1211313

📥 Commits

Reviewing files that changed from the base of the PR and between 8437683 and 044f2d0.

📒 Files selected for processing (3)
  • src/io/PipeReader.rs
  • src/runtime/webcore/FileReader.rs
  • test/js/node/child_process/child_process.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix is up for review.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #38886, which now reworks the read loop so on_pull reads straight into the pull buffer (read_into); verified the test from this PR passes on that branch at e424871 and still fails on main. Standing down.

@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 reworks the ReadDuringJSOnPullResult state machine in memory-safety-critical code (FileReader backs every piped stdio / process.stdin / Bun.file().stream() consumer), a maintainer look would still be worthwhile.

What was reviewed:

  • Traced the Js → UseBuffered transition: bytes already copied into the pull buffer are prepended to buffered before the chunk, so ordering is preserved and nothing already-delivered is dropped.
  • Checked that buf cannot alias self.buffered on this path (on_pull asserts buffered is empty before the read; chunks come from scratch or reader._buffer), so the removed is_slice_in_vec_capacity guard is not needed here.
  • Verified the dropped ret special-case for Temporary only affected the !has_more (EOF) branch, where the read loop is done regardless of the return value.
  • Test uses file-marker handshakes with deadlines instead of sleeps, drains all pipes concurrently, and asserts exact byte content — looks deterministic.
Extended reasoning...

Overview

The PR fixes a heap-use-after-free in FileReader::on_pull: when a nested read (triggered from inside a 'data' handler) delivered an EOF chunk larger than the pull buffer, the old code stored a raw slice of that chunk in ReadDuringJSOnPullResult::Temporary and copied it out after read() returned — but since #38656, nested reads use a heap Vec that is freed on the way out of read_with_fn, so the copy read freed memory. The fix removes the borrowed Temporary variant entirely: chunks that don't fit the pull buffer are copied into FileReader::buffered immediately (along with any bytes already in the pull buffer), and on_pull returns buffered as an owned result. The unused AmountRead variant and UseBuffered's size field are also removed. A doc comment on BufferedReaderParent::on_read_chunk now states the chunk-lifetime contract.

Security risks

None. This is an internal buffer-lifetime fix; no user-controlled input parsing, no auth/crypto/permissions.

Level of scrutiny

High. FileReader sits behind process.stdin, spawned stdio streams, and Bun.file().stream(). The change touches &'static mut lifetime-laundered buffers, nested JsCell::with_mut projections, and the highwater-mark backstop that governs whether the read loop continues. The reasoning in the PR description is thorough and the state machine is strictly simpler after the change (three variants, none borrowing), but the blast radius of a mistake here is every stream consumer.

Other factors

  • The bug hunting system found no issues.
  • I traced the aliasing question (whether buf can point into self.buffered on the read_inside_on_pull path) and confirmed it cannot: on_pull asserts buffered is empty before issuing the read, and chunks originate from the per-loop scratch or reader._buffer, never FileReader::buffered.
  • The removed ret special-case for Temporary only mattered on the !has_more branch, which is EOF — the read loop stops regardless of the return there.
  • The new test is well-constructed: file-marker handshakes with 15s deadlines instead of sleeps, exact byte-content assertions, concurrent pipe draining, and a documented ASAN repro on unfixed main. It's POSIX-only (skipIf(!isPosix)), which matches the POSIX-specific synchronous-read path where the bug lives.
  • No prior human review comments to address.

Given the memory-safety stakes and hot-path surface area, deferring to a maintainer rather than auto-approving.

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.

2 participants