Skip to content

Fix use-after-free of stream error objects parked in ByteStream#31963

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/6c72759e/s3-stream-error-gc-root
Jun 23, 2026
Merged

Fix use-after-free of stream error objects parked in ByteStream#31963
Jarred-Sumner merged 1 commit into
mainfrom
farm/6c72759e/s3-stream-error-gc-root

Conversation

@robobun

@robobun robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a use-after-free found by Fuzzilli (fingerprint StructureID.h(93), ASSERTION FAILED: decontaminate() in StructureID::decode()).

StreamError::JSValue held a raw JSValue with no GC root. When an S3 download stream failed before a consumer attached (missing credentials fail synchronously inside .stream()), the error object was parked in ByteStream.pending.result across event-loop turns. A GC before the stream was consumed freed it, so .text() rejected with whatever now lived in the recycled cell, or crashed on a dead cell. Deterministic on an unfixed build:

const stream = Bun.S3Client.file("some-key").stream();
Bun.gc(true);
for (let i = 0; i < 100; i++) new TypeError("decoy " + i);
await stream.text().catch(e => e); // rejects with TypeError: decoy 0 (recycled cell)

The fetch, request body size limit, and Body::ValueError::to_stream_error producers had the same shape.

The fix

StreamError::JSValue now holds jsc::strong::Optional, which keeps the value rooted while held and releases it on Drop (implements the existing TODO on the variant).

This deletes most of the surrounding machinery: to_js_weak becomes to_js, the WasStrong enum and every consumer-side if was_strong == Strong { unprotect() } branch are gone, the unused WeakJSValue variant is gone, StreamError is no longer Clone, RequestContext::on_pipe drops its raw-pointer scopeguard::defer!/unsafe block (the by-value result drops naturally in the trait impl), and ByteStream::on_data moves a parked error instead of cloning it.

How did you verify your code works?

  • New test test/js/bun/s3/s3-stream-error-gc.test.ts fails on the unfixed build (rejects with a recycled decoy object) and passes with this change.
  • The original fuzzer script no longer asserts.
  • Ran the S3 test directory, test/js/web/streams/streams.test.js, test/js/web/fetch/{body-stream,body-mixin-errors,fetch.stream}.test.ts, test/js/web/streams/readable-stream-blob-consumed.test.ts, and test/js/bun/http/serve-pending-promise-abort-leak.test.ts against the debug build; only behavioral delta vs an unmodified build is the new test passing (the remaining failures are pre-existing 5s debug+ASAN timeouts that reproduce identically on the unmodified debug build).
  • cargo clippy -p bun_runtime and bun run rust:check-all pass.

@github-actions github-actions Bot added the claude label Jun 8, 2026
@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:00 PM PT - Jun 21st, 2026

@robobun, your commit c129560 has 3 failures in Build #63845 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31963

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

bun-31963 --bun

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. s3: protect stream error JSValue before handing it to ByteStream #29822 - Fixes the same use-after-free of unrooted StreamError::JSValue in ByteStream, scoped to the S3 producer site; PR Fix use-after-free of stream error objects parked in ByteStream #31963 is a superset covering all producer and consumer paths
  2. fetch: protect stream error JSValue stored in ByteStream pending result #29745 - Fixes the same unrooted StreamError::JSValue bug from the fetch producer site; PR Fix use-after-free of stream error objects parked in ByteStream #31963 subsumes this fix

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Stream errors are now protected and released with explicit GC pairing: a new StreamError::strong protects JS values, ByteStream/ResumableSink release parked errors safely, multiple call sites create strong stream errors, and an integration test verifies GC safety for parked S3 errors.

Changes

Stream error GC lifecycle fixes

Layer / File(s) Summary
StreamError lifecycle contract and BufferAction rejection
src/runtime/webcore/streams.rs
New StreamError::strong() helper wraps a protected JSValue and documents single unprotect semantics. BufferAction::reject now calls to_js_weak(), ensures the error stays alive, conditionally unprotects when needed, and rejects the promise with the balanced result.
ByteStream explicit release and finalization
src/runtime/webcore/ByteStream.rs
ByteStream applies the lifecycle pattern: on_data parameter becomes mutable to allow explicit release(), append releases pending errors before overwriting, finalize adds an else branch for already-done streams to drop unclaimed errors, and to_buffered_value keeps errors alive until explicit release.
ResumableSink error ownership pattern
src/runtime/webcore/ResumableSink.rs
ResumableSink takes ownership of parked pending errors via pattern-matching and mem::replace, ensuring pending result transitions to Done to prevent double-unprotect during later finalization.
Stream error creation at call sites
src/runtime/server/RequestContext.rs, src/runtime/webcore/Body.rs, src/runtime/webcore/fetch/FetchTasklet.rs, src/runtime/webcore/s3/client.rs
Request body buffering, generic body error conversion, fetch streaming errors, and S3 download errors now use StreamError::strong() instead of unprotected JSValue wrapping, ensuring all error creation sites participate in the new lifecycle.
S3 stream error GC integration test
test/js/bun/s3/s3-stream-error-gc.test.ts
New test spawns a child process that creates an S3 stream, forces garbage collection before stream consumption, and asserts the parked error remains valid across the GC cycle with the correct error code.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix use-after-free of stream error objects parked in ByteStream' directly describes the main issue and fix in this PR, which aligns with the primary change across all modified files.
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.
Description check ✅ Passed The PR description fully addresses both required sections: it clearly explains the use-after-free issue and the fix, and provides comprehensive verification details including test results and test suite runs.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/webcore/streams.rs (1)

339-362: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make Clone re-protect strong JS errors.

StreamError::strong() turns StreamError::JSValue into an owning handle, but #[derive(Clone)] still bit-copies that ownership. A cloned strong error can now reach multiple unprotect() paths off a single protect(), which reopens the same GC/UAF class this PR is fixing. Please either drop Clone here or implement it manually so cloning a strong variant calls protect() again.

♻️ Proposed fix
-#[derive(Clone)]
 pub enum StreamError {
     Error(SysError),
     AbortReason(CommonAbortReason),
     // TODO: use an explicit jsc.Strong.Optional here.
     JSValue(JSValue),
     WeakJSValue(JSValue),
 }
+
+impl Clone for StreamError {
+    fn clone(&self) -> Self {
+        match self {
+            Self::Error(err) => Self::Error(err.clone()),
+            Self::AbortReason(reason) => Self::AbortReason(reason.clone()),
+            Self::JSValue(value) => {
+                value.protect();
+                Self::JSValue(*value)
+            }
+            Self::WeakJSValue(value) => Self::WeakJSValue(*value),
+        }
+    }
+}

As per coding guidelines, every acquisition must have exactly one owner/release, and JS values kept alive on the JS thread need explicit ownership semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/webcore/streams.rs` around lines 339 - 362, The derived Clone for
StreamError bit-copies the protected JSValue so cloning a strong variant
duplicates ownership and allows multiple unprotects; remove #[derive(Clone)] and
implement Clone manually for StreamError: match each variant and clone its
payload normally for Error(SysError) and AbortReason(CommonAbortReason) and for
WeakJSValue(JSValue) simply clone the JSValue, but for the strong JSValue
variant call value.protect() on the JSValue being cloned before returning
StreamError::JSValue(cloned_value) so each clone acquires its own protect; leave
WasStrong as-is.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/s3/s3-stream-error-gc.test.ts`:
- Around line 16-19: Replace the .then() chain used to capture the error from
stream.text() with async/await and a try/catch: await stream.text() inside a try
block and assign the caught error to the existing err variable in the catch
block so the test follows the repo rule for promises; update any surrounding
async function or test to ensure it is marked async if not already, and keep
references to err and stream.text() intact.

---

Outside diff comments:
In `@src/runtime/webcore/streams.rs`:
- Around line 339-362: The derived Clone for StreamError bit-copies the
protected JSValue so cloning a strong variant duplicates ownership and allows
multiple unprotects; remove #[derive(Clone)] and implement Clone manually for
StreamError: match each variant and clone its payload normally for
Error(SysError) and AbortReason(CommonAbortReason) and for WeakJSValue(JSValue)
simply clone the JSValue, but for the strong JSValue variant call
value.protect() on the JSValue being cloned before returning
StreamError::JSValue(cloned_value) so each clone acquires its own protect; leave
WasStrong as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1d87c9cb-3063-4751-a7f9-95aca6ccc302

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and 6ac8d4f.

📒 Files selected for processing (8)
  • src/runtime/server/RequestContext.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/ByteStream.rs
  • src/runtime/webcore/ResumableSink.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • src/runtime/webcore/s3/client.rs
  • src/runtime/webcore/streams.rs
  • test/js/bun/s3/s3-stream-error-gc.test.ts

Comment thread test/js/bun/s3/s3-stream-error-gc.test.ts Outdated
Comment thread test/js/bun/s3/s3-stream-error-gc.test.ts Outdated
@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

Regarding the out-of-band finding on StreamError deriving Clone: valid concern, addressed in 4ca49b7 with a different shape than the proposed re-protecting Clone. The repo had exactly one clone site, ByteStream::on_data parking an incoming error into the pending result, and that site transfers ownership by dropping the original without a release. A Clone impl that re-protects would have turned that site into a protect leak (2 protects, 1 unprotect). Instead the site now moves the error out of the incoming result with mem::replace, and Clone is removed from StreamError entirely so an owning copy cannot be created by accident.

Comment thread src/runtime/webcore/streams.rs Outdated

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/Body.rs`:
- Around line 2312-2323: on_stream_pipe currently ignores streams::Result::Err
and always calls (self.on_finished_buffering) with the success path; change it
to detect when stream.is_err() (or pattern-match streams::Result) and propagate
that failure into the callback by calling (self.on_finished_buffering)(self.ctx,
bytes_or_partial, Some(ValueError::from(stream.error())) , true/false as
appropriate) instead of silently releasing only; ensure you still call
stream.release() to drop gcProtect but build and pass a proper ValueError (or
JSValue error) to self.on_finished_buffering so the error path is taken by
callers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0c36ee5e-9946-429b-bad6-93522ce919b7

📥 Commits

Reviewing files that changed from the base of the PR and between 4ca49b7 and e92d3c1.

📒 Files selected for processing (2)
  • src/runtime/server/RequestContext.rs
  • src/runtime/webcore/Body.rs

Comment thread src/runtime/webcore/Body.rs Outdated

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

Thanks for addressing both findings — the e92d3c1 change to call stream.release() in RequestContext::on_pipe and ValueBufferer::on_stream_pipe closes the piped-error leak. I didn't find anything further, but given this rebalances gcProtect/gcUnprotect across ~10 producer/consumer sites in core stream/fetch/server paths, it's worth a human pass to sanity-check the invariant.

Extended reasoning...

Overview

This PR fixes a use-after-free where StreamError::JSValue error objects were stored unrooted in ByteStream.pending.result across event-loop turns, allowing GC to collect them before consumption. The fix introduces StreamError::strong() (which takes a gcProtect) at all four producer sites (S3 download, fetch tasklet, request body size limit, Body::ValueError::to_stream_error), and audits every consumer path to release exactly once — BufferAction::reject, ByteStream::{on_data, append, finalize, to_buffered_value}, ResumableSink::init, and (after my earlier finding) the two remaining PipeHandler impls in RequestContext::on_pipe and ValueBufferer::on_stream_pipe. Clone is removed from StreamError so the protect count can't be aliased by a bitwise copy. A new regression test (s3-stream-error-gc.test.ts) reproduces the UAF deterministically.

Security risks

The original bug is a GC use-after-free in production code paths — promise rejection with a recycled cell is type confusion, and the debug build hits a StructureID assertion. The fix itself is the security improvement; the residual risk is an unbalanced protect count (leak if a consumer path is missed, potential over-unprotect if a path releases twice). I traced every StreamError::JSValue construction (now all via strong()) and every consumption site I could find, and the balance looks correct after e92d3c1, but this is exactly the class of invariant where a second pair of eyes is valuable.

Level of scrutiny

High. This touches the core streaming error lifecycle across ByteStream, ResumableSink, fetch, S3, the HTTP server's request body path, and Body buffering — every stream-error path in the runtime. The invariant ("one protect per JSValue variant, released exactly once") is enforced only by convention plus the Clone removal; a missed or duplicated release in any of the ~10 sites would be a regression. My first review of this PR did find one such gap (the two PipeHandler impls), which was fixed in e92d3c1.

Other factors

  • Both of my prior inline findings (test stderr snapshot, PipeHandler protect leak) and CodeRabbit's nit have been addressed.
  • The bug-hunting system found nothing on the latest revision.
  • No CODEOWNERS coverage for these paths.
  • Two earlier PRs (#29822, #29745) were subsets of this fix scoped to single producer sites, suggesting this area has been tricky to get right.
  • The mem::replace-based ownership transfers in ByteStream::on_data and ResumableSink::init look correct, and to_buffered_value now resets to Done after release to prevent a double-unprotect — but these are subtle enough that a maintainer familiar with the JSC protect model should confirm.

@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for the latest build (#61345, commit 99e6776): 2 failures, both unrelated to this change.

The earlier build #61344 additionally had six unrelated tests failing together on a single macOS aarch64 runner (docker builds, mysql, websocket-proxy, init), which cleared on the rebuild.

@robobun

robobun commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

Build #61669 (after the merge from main) had no test failures: 68 jobs ran and passed, 25 jobs expired waiting for an agent (macOS and linux capacity), and the remaining 193 steps cascaded to waiting_failed off those. No job exited non-zero.

The two earlier blockers are gone: the tree-wide bunx.test.ts failure is skipped upstream (#32042, now included in this branch), and the duckdb segfault did not reproduce. Retrying the failed jobs on #61669, or a fresh build, should go green.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • Rebase
  • Remove usages of unsafe
  • Prefer Drop() over manually freeing things
  • Delete code comments

StreamError::JSValue held a raw JSValue with no GC root while parked in
ByteStream.pending.result across event-loop turns. A GC between the error
arriving and the stream being consumed freed the error object, so the
promise rejected with a recycled unrelated object or a dead cell
(StructureID decode assertion).

Change StreamError::JSValue to hold a jsc::strong::Optional, which keeps
the value rooted while held and releases on Drop. All four producers now
create a Strong; the WasStrong enum, to_js_weak, the consumer-side
unprotect branches, and the unused WeakJSValue variant are deleted. The
manual release scaffolding in RequestContext::on_pipe and
ValueBufferer::on_stream_pipe (including the raw-pointer scopeguard) is
removed since the by-value result now drops at scope exit in the trait
impl.
@robobun robobun force-pushed the farm/6c72759e/s3-stream-error-gc-root branch from bb98e88 to c129560 Compare June 22, 2026 00:21
@robobun

robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in c129560 on top of current main (7a5293c):

  • Rebase: branch is now a single commit on top of main; force-pushed.
  • Prefer Drop over manually freeing: StreamError::JSValue now holds jsc::strong::Optional (implements the existing TODO on the variant). The GC root is released by Drop when the StreamError drops or is overwritten, so the WasStrong enum, to_js_weak, every consumer-side if was_strong == Strong { unprotect() } branch, the unused WeakJSValue variant, the derived Clone on StreamError, and the protect()-based StreamError::strong() helper from the earlier revision are all gone.
  • Remove usages of unsafe: the raw-pointer scopeguard::defer! + unsafe { &mut *stream_ptr } in RequestContext::on_pipe is deleted (the by-value StreamResult drops naturally in the PipeHandler trait impl; the inherent fn takes &StreamResult). ValueBufferer::on_stream_pipe is simplified the same way. No new unsafe in the diff.
  • Delete code comments: all the explanatory comments from the earlier revisions are gone along with the stale "Intentionally not Drop" and the now-implemented TODO.

Net change is 8 files, +105/-132. cargo clippy -p bun_runtime and bun run rust:check-all (all 10 targets) pass. The regression test still fails on the unfixed build and passes with this change; the S3, streams, body-stream (9086 tests), fetch.stream, body-mixin-errors, readable-stream-blob-consumed, and serve-pending-promise-abort-leak suites show no delta vs an unmodified debug build (the remaining failures are the pre-existing 5s debug+ASAN timeouts).

@robobun robobun requested a review from Jarred-Sumner June 22, 2026 00:22
@robobun

robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Build #63845 (the jsc::strong::Optional rework, c129560): 284 jobs passed, 2 darwin aarch64 shards failed with three tests that are failing across unrelated recent PRs.

  • test/integration/next-pages/test/dev-server-ssr-100.test.ts and next-build.test.ts on macOS 26 aarch64: puppeteer chrome-headless-shell download failure ("browser folder exists but the executable is missing"). Also failing on build #63820 (unrelated branch).
  • test/js/node/test/parallel/test-tls-client-destroy-soon.js on macOS 14 aarch64: byte-count assertion 2097152 vs 2048000. Failing the same way on builds #63835, #63829, #63827, #63820 from unrelated branches.

No stream, fetch, S3, or serve test failures; the suites that exercise the changed code paths all passed.

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

No issues found in the reworked jsc::strong::Optional-based approach — it's cleaner than the earlier protect/unprotect version and the by-value drop in the PipeHandler trait impls now covers the leak path I flagged before. Still deferring given this reshapes GC-rooting/ownership across eight runtime files (streams, fetch, S3, Bun.serve); worth a human pass.

Extended reasoning...

Overview

This PR fixes a use-after-free where StreamError::JSValue held a raw, unrooted JSValue that could be GC'd while parked in ByteStream.pending.result. The final revision (c129560) replaces the payload with jsc::strong::Optional, which roots on create and unroots in its Drop impl (verified at src/jsc/Strong.rs:169). That lets the PR delete the WasStrong enum, to_js_weak, the unused WeakJSValue variant, the Clone impl on StreamError, and the manual scopeguard::defer!/unsafe cleanup in RequestContext::on_pipe — the trait-level on_pipe(&mut self, stream: StreamResult) now owns the value and forwards it by & to the inherent method, so both the Err strong handle and the Owned* Vec<u8> free naturally on scope exit. Touches streams.rs, ByteStream.rs, Body.rs, ResumableSink.rs, ReadableStream.rs, RequestContext.rs, FetchTasklet.rs, s3/client.rs, plus a new regression test.

Security risks

None identified. This is a memory-safety fix (GC rooting), not an auth/permission/input-validation surface. The failure mode being fixed is a UAF (potential type confusion via recycled JSC cells), so the change reduces risk rather than adding it.

Level of scrutiny

High. This is production-critical runtime code on hot paths (every fetch/S3/Bun.serve streamed body error flows through here), and the change reshapes ownership semantics: StreamError loses Clone, on_data now mem::replaces the parked error instead of cloning, on_pipe switches from owning+manual-cleanup to borrow+natural-Drop, and upload_stream in s3/client.rs now moves the pending error out via mem::replace before reading it. Each of these is individually straightforward, but the earlier revision of this same PR had a real protect-leak regression (which I flagged and which was fixed), and the approach was then substantially reworked — so this is exactly the class of change where a second human reading the final diff is worthwhile.

Other factors

The bug-hunting system found nothing on the current revision. All prior review threads (mine, coderabbit's) are resolved, and the test now snapshots {stdout, stderr, exitCode} per the earlier feedback. The new test deterministically reproduces the UAF on an unfixed build. The PR description accurately reflects the final shape (Drop-based, not the intermediate StreamError::strong()/WasStrong design that coderabbit's stale walkthrough still describes). CI on the latest commit was building at the time of review; earlier builds were green modulo unrelated/infra failures. I'm not approving solely because of scope and subtlety, not because of any specific concern with the current diff.

@Jarred-Sumner Jarred-Sumner enabled auto-merge (squash) June 23, 2026 07:10
@Jarred-Sumner Jarred-Sumner disabled auto-merge June 23, 2026 07:10
@Jarred-Sumner Jarred-Sumner merged commit 7ff1181 into main Jun 23, 2026
76 of 78 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/6c72759e/s3-stream-error-gc-root branch June 23, 2026 07:11
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