Skip to content

blob: stop aliasing store.mime_type.value with blob.content_type in response.blob()#29947

Closed
robobun wants to merge 5 commits into
mainfrom
farm/e47d6495/blob-store-mime-uaf
Closed

blob: stop aliasing store.mime_type.value with blob.content_type in response.blob()#29947
robobun wants to merge 5 commits into
mainfrom
farm/e47d6495/blob-store-mime-uaf

Conversation

@robobun

@robobun robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

What

Body.Value.resolve (the .getBlob arm) and Body.Mixin.getBlobWithThisValue set up the Blob returned from response.blob(). When the response's Content-Type is a category that MimeType.init heap-allocates (image/, audio/, video/, font/, most application/*, …), both places did:

const mimeType = MimeType.init(slice, bun.default_allocator, &allocated);
blob.content_type = mimeType.value;            // owned by blob
blob.content_type_allocated = allocated;       // -> freed in Blob.deinit
blob.store.?.mime_type = mimeType;             // Store aliases the SAME allocation

The Store is refcounted and shared — blob.slice() keeps it alive while the slice's own content_type is "". When the original Blob is collected first, Blob.deinit frees content_type; the Store survives with mime_type.value dangling. sliced.type then reads it via Blob.getType's store fallback → use-after-free.

Related to #29910, which fixed the same aliasing inside dupeWithContentType; this is an independent occurrence at the point response.blob() materializes the mime type.

Repro

let blob = await (await fetch(urlWithImagePng)).blob();
const sliced = blob.slice(0, 1);
blob = null;
Bun.gc(true); await Bun.sleep(0); Bun.gc(true);
sliced.type; // ASAN: use-after-poison

ASAN trace on main:

==3034==ERROR: AddressSanitizer: use-after-poison on address 0x7954c2370140
READ of size 9 at 0x7954c2370140 thread T0
    #0 __asan_memcpy
    #1 Zig::toStringCopy(ZigString) helpers.h:217
    #2 ZigString__toValueGC bindings.cpp:3423
    #4 bun.js.webcore.Blob.getType Blob.zig:2997

Fix

Give the Store its own copy of the mime string when it's heap-allocated, and free it in Store.deinit:

  • Blob.Store: add mime_type_allocated: bool = false; deinit frees mime_type.value when set.
  • Body.Value.resolve / getBlobWithThisValue: when MimeType.init allocated, dupe the string for the Store and set mime_type_allocated = true. Static mime strings continue to be stored by reference with the flag false (no behaviour change, no free).

Blob and Store now each own their allocation with independent lifetimes; sliced.type reads the Store's copy, which lives until the last ref drops.

Verified

  • New tests in test/js/web/fetch/blob.test.ts cover both code paths (fetch → resolve, and sync new Response(...).blob()getBlobWithThisValue).
  • Without fix (ASAN): both tests fail with AddressSanitizer: use-after-poison in Blob.getType.
  • With fix: bun bd test test/js/web/fetch/blob.test.ts — 18/18 pass.

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:06 PM PT - May 4th, 2026

@robobun, your commit 6713c82 has 3 failures in Build #51418 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29947

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

bun-29947 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Mime-type not preserved with response.blob() #9735 - response.blob().type returns empty string for heap-allocated MIME types like image/jpeg, which is exactly the symptom of the use-after-free this PR fixes
  2. Heap corruption (malloc free list) during long-running server with sharp + mongodb #27929 - Long-running server sees malloc: Heap corruption detected, free list is damaged and double-free errors during fetch workloads; the MIME type double-free fixed here could corrupt the malloc free list

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #9735
Fixes #27929

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 23aadff5-6080-4364-a74f-991ebc95433a

📥 Commits

Reviewing files that changed from the base of the PR and between 889194d and 653364d.

📒 Files selected for processing (1)
  • test/js/web/fetch/blob.test.ts

Walkthrough

Tracks and fixes ownership of heap-allocated Blob MIME strings so stores do not alias blob-owned MIME data. Adds Store.setMimeType and an ownership flag, updates Body MIME propagation to duplicate allocated MIME strings, and adds a regression test exercising fetch and sync blob paths under ASAN.

Changes

Cohort / File(s) Summary
Blob store: ownership and API
src/bun.js/webcore/blob/Store.zig
Adds mime_type_allocated: bool, new pub fn setMimeType(this: *Store, mime: MimeType, allocated: bool) void, updates deinit to free heap-allocated MIME when owned, and adjusts MIME replacement logic to honor ownership.
Body MIME propagation
src/bun.js/webcore/Body.zig
When resolving getBlob/getBlobWithThisValue, duplicates mimeType.value to heap if allocated is true and calls store.setMimeType(..., allocated); for unset content type uses setMimeType(MimeType.text, false) instead of direct assignment.
Regression test
test/js/web/fetch/blob.test.ts
Adds a parameterized test that spawns a Bun subprocess (fetch vs sync Response), forces GC, and verifies sliced.type === "image/png" and no stderr/crash under ASAN to catch use-after-free/aliasing.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: stopping MIME-type aliasing in response.blob() by addressing how store.mime_type.value is managed relative to blob.content_type.
Description check ✅ Passed The description comprehensively covers both required sections: 'What does this PR do?' (extensive explanation with code example, root cause, and fix) and 'How did you verify your code works?' (test coverage and ASAN verification).
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.

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


Review rate limit: 3/5 reviews remaining, refill in 18 minutes and 55 seconds.

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/webcore/Body.zig`:
- Around line 751-763: The store's ownership flag isn't reset when falling back
to the static MimeType.text value, so Store.deinit may free a static pointer;
update the fallback branch that assigns store.mime_type = MimeType.text (and the
analogous assignment at the other location) to also set
store.mime_type_allocated = false (and ensure any previous allocated value is
freed beforehand, mirroring the header-present branch behavior); locate the
logic around blob.store / store.mime_type / store.mime_type_allocated and the
fallback assignment to MimeType.text and make the flag change there.
🪄 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: 4c8b9cbc-b244-4933-848e-133036388679

📥 Commits

Reviewing files that changed from the base of the PR and between 6c21a7e and 043ee03.

📒 Files selected for processing (3)
  • src/bun.js/webcore/Body.zig
  • src/bun.js/webcore/blob/Store.zig
  • test/js/web/fetch/blob.test.ts

Comment thread src/bun.js/webcore/Body.zig Outdated
@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

On the suggested issues:

Leaving both out of the PR description.

@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 the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/webcore/Body.zig`:
- Around line 751-763: Extract the duplicated MIME propagation logic into a
single helper (e.g., a function like propagateMimeTypeToStore or
setStoreMimeType) that accepts the Store reference, the mimeType, and the
allocated flag and performs the current behavior (allocating via
bun.default_allocator.dupe, calling bun.handleOom on failure, and preserving
mimeType.category), then replace the repeated blocks in Blob (the
store.setMimeType calls around Blob.deinit ownership handling) and the other
occurrences (lines around 766-771, 1437-1449, 1452-1457) to call this helper;
ensure the helper mirrors the exact allocation/oom semantics and keeps ownership
semantics identical to the original store.setMimeType invocations so behavior
and refcounting remain unchanged.
🪄 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: a7ec0e19-7c8b-408f-8762-cdcfeb3e06b4

📥 Commits

Reviewing files that changed from the base of the PR and between 043ee03 and c4c1af4.

📒 Files selected for processing (2)
  • src/bun.js/webcore/Body.zig
  • src/bun.js/webcore/blob/Store.zig

Comment thread src/runtime/webcore/Body.zig
Comment thread src/bun.js/webcore/Body.zig Outdated
Comment thread test/js/web/fetch/blob.test.ts Outdated
@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

Build 49201: blob.test.ts passes on all lanes — the LSan/symbolize interaction is fixed.

Remaining failures are pre-existing flakes on main (6c21a7e), none touch the changed code:

test cause also failing on
fetch-http2-client.test.ts (asan) ASSERTION FAILED: wasRemoved (AtomStringImpl) in experimental HTTP/2 subprocess — uses .text(), not .blob() #49197, #49196, #49181
bun-create.test.ts (win) GitHub returned 403 rate limit #49200, #49197, #49196, #49180
bake/dev/stress.test.ts (win) dev-server timeout #49193 (flaky annotation)

@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 — 653364d addresses the test-coverage note (the fetch case now streams so resolve()'s .getBlob arm is actually exercised), and the .text fallback going through setMimeType() covers the earlier ownership-flag concern. I didn't find anything else, but since this adds a new ownership invariant to the shared/refcounted Blob.Store and changes Store.deinit, it's worth a maintainer's pass.

Extended reasoning...

Overview

This PR fixes a use-after-free in response.blob() where blob.content_type and blob.store.mime_type.value aliased the same heap allocation for non-static MIME types (image/, audio/, etc.). When the original Blob was GC'd while a slice still held the Store, Blob.deinit freed the string and the Store's pointer dangled. The fix:

  • src/bun.js/webcore/blob/Store.zig: adds mime_type_allocated: bool field, a setMimeType() helper that frees any prior heap value before assigning, and frees in deinit().
  • src/bun.js/webcore/Body.zig: at both response.blob() materialization sites (Body.Value.resolve's .getBlob arm and getBlobWithThisValue), the Store now gets its own dupe of the mime string when heap-allocated, via setMimeType(). The MimeType.text fallback also routes through setMimeType() so a pre-allocated store can't end up with the flag set on a static string.
  • test/js/web/fetch/blob.test.ts: subprocess regression test covering both code paths under ASAN.

Security risks

None. This is a memory-safety hardening (UAF fix); no auth, crypto, or untrusted-input parsing changes. The only new behavior is an extra small allocation per response.blob() call when the Content-Type is a non-static category.

Level of scrutiny

Moderate-to-high. The change is small and well-scoped, mirrors the pattern from #29910, and has a verified-failing-on-main ASAN regression test. However, it introduces a new ownership invariant on Blob.Store — a refcounted, shared structure on the hot path of every fetch().blob() — and adds a free in Store.deinit(). I grepped for other direct store.mime_type = writes: the only one outside setMimeType() is Blob.zig:570 on a freshly-created store with a static value, which is safe. Still, ownership changes to shared refcounted state in unmanaged Zig are exactly where subtle lifetime bugs hide, so a maintainer who knows the Blob/Store model should confirm the invariant is sound across all paths (including any future Store reuse/pooling).

Other factors

  • Both of my prior inline comments have been addressed: c4c1af4 routed the .text fallback through setMimeType() (closing the static-free risk), and 653364d switched the fetch test to a delayed ReadableStream so the resolve() arm is actually covered.
  • CodeRabbit's concerns are resolved.
  • CI failures (fetch-http2-client, bun-create, bake/dev/stress) are pre-existing flakes on main per robobun's analysis; blob.test.ts itself passes on all lanes.
  • No bugs found by the bug-hunting system on the current revision.

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

Build 49219: blob.test.ts passes on all lanes. No failures related to this change.

Remaining failures are pre-existing flakes also seen on other unrelated branches off the same main commit (6c21a7e):

test lane cause also failing on
s3-storage-class.test.ts darwin-14-aarch64 S3Error: UnknownError on "writer + options on big file" #49191, #49189, #49179, #49177
(earlier shard) darwin-14-aarch64 Expired — no agent before scheduling timeout; retry above

The S3 init paths use static extension-derived mime strings, so Store.mime_type_allocated stays false and the new Store.deinit branch is a no-op there.

All other test noise is in the "flaky (passed on retry)" warning bucket — fetch-http2-client, bake/dev, hot, bun-install-*, etc.

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author

Independently arrived at the same fix on farm/da5b1588/blob-store-mime-uaf — same mime_type_allocated flag on Store + free in Store.deinit, but transfers ownership of the single allocation to the Store and has the Blob borrow (content_type_allocated = false) instead of duping a second copy. Either approach works; leaving the branch for reference and deferring to this PR.

robobun added 4 commits May 4, 2026 10:27
…esponse.blob()

Body.Value.resolve and Body.Mixin.getBlobWithThisValue both assigned the
heap-allocated MimeType.value to both blob.content_type (owned, freed
in Blob.deinit) and store.mime_type.value (not owned). The Store is
refcounted and shared with slices, so it can outlive the Blob. After
the original is collected, reading sliced.type falls through to
store.mime_type.value -> use-after-free.

Give the Store its own copy when the mime string is heap-allocated,
tracked by Store.mime_type_allocated and freed in Store.deinit.
Ensures mime_type_allocated is reset when overwriting with a static value.
symbolize=0 breaks LSan's symbol-name suppression matching, so CI's
normally-suppressed leaks (uws_create_app, etc.) surfaced on the fetch
variant. We only care about the UAF here.
A fully-buffered 11-byte body lands before fetch() resolves, so both
test.each cases were taking getBlobWithThisValue. Serve a delayed
ReadableStream (same pattern as the #10072 test) so the fetch case hits
Body.Value.resolve's .getBlob arm.

Emit the server at module scope rather than inside a block-scoped
await using — the extra async-dispose resume point left a stack slot
that conservatively rooted the original blob across Bun.gc(), so the
finalizer never ran and the pre-fix UAF didn't reproduce.
@Jarred-Sumner Jarred-Sumner force-pushed the farm/e47d6495/blob-store-mime-uaf branch from 653364d to f8e1193 Compare May 4, 2026 10:27

@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 didn't find any further issues — both of my earlier inline comments were addressed and CI is green. Deferring to a human reviewer since this adds a new ownership invariant (mime_type_allocated) to the shared refcounted Store struct, and memory-lifetime changes in this hot path are worth a second pair of eyes.

Extended reasoning...

Overview

This PR fixes a use-after-free in response.blob() where the Blob's heap-allocated content_type and the shared Store.mime_type.value aliased the same allocation. It adds Store.mime_type_allocated: bool, a Store.setMimeType() helper that frees any prior owned value, frees the owned value in Store.deinit(), and updates four call sites in Body.zig (two header-present blocks + two MimeType.text fallbacks in Body.Value.resolve and getBlobWithThisValue) to dupe the mime string into the Store rather than alias the Blob's. A parameterized subprocess regression test in test/js/web/fetch/blob.test.ts covers both code paths under ASAN.

Security risks

None. This is internal memory-ownership bookkeeping for a small mime-type string; no auth, crypto, permissions, or untrusted-input parsing is touched.

Level of scrutiny

Moderate-to-high. The diff is small (~50 LOC of Zig + tests) and the fix is conservative (give the Store its own copy), but it introduces a new ownership invariant on a refcounted struct shared across many Blob instances. Getting this class of change wrong produces UAF/double-free in a hot path. The PR already went through two rounds of review feedback (the MimeType.text fallback flag-reset issue, and the test not actually exercising the resolve() arm), both now addressed via Store.setMimeType() and a streaming-body test variant. I verified the remaining direct store.mime_type = ... assignments outside this PR (Blob.zig:709, standalone_graph_jsc.zig:14) are on freshly-created stores with static registry mimes, so the new invariant holds there. The author also noted an alternative design (transfer ownership to Store, have Blob borrow) on a separate branch — that's a judgment call a human maintainer may want to weigh.

Other factors

  • All prior inline comments (CodeRabbit's and mine) are resolved.
  • CI is green on the changed test; remaining failures are documented pre-existing flakes (s3-storage-class, fetch-http2-client, etc.) unrelated to Blob/Store.
  • The new test is well-constructed: subprocess isolation, ASAN-aware (symbolize=0:detect_leaks=0), and the streaming variant was verified to actually reach Body.Value.resolve.
  • This is the second PR in a related series (#29910 fixed the same aliasing in dupeWithContentType), so a maintainer familiar with that one is well-placed to review.

@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for this PR

`blob.test.ts` passes on every lane it has run on across builds 49201, 49219, 51028, and 51418. The rebased code (post src/-restructure) verified locally with `zig:check`. All bot reviews approve; 0 unresolved threads.

All CI "failures" since 889194d are infrastructure or pre-existing flakes, none in code touched by this PR:

Build Cause
49201 `fetch-http2-client` (AtomStringImpl assert — also on #49197/#49196/#49181), `bun-create` (GitHub 403), `bake/stress` (win flake)
49219 `s3-storage-class` (`S3Error: UnknownError` — also on #49191/#49189/#49179/#49177) + darwin agent Expired
51028 CI agent outage: 15 build jobs Expired → 232 tests never ran. Main's own build #50956 on the same base commit failed identically. + `s3-storage-class`, `node-http-backpressure-max`, `test-http-should-emit-close` flakes
51418 CI agent outage continues: 12 build jobs Expired → 216 tests never ran. `robobun` annotation = "Failed to create agent for 2019 x64 — operation timed out" (also on #51413/#51414). + same `s3-storage-class` / `test-http-should-emit-close` flakes (also on #51410-#51415)

This PR needs a Buildkite retry once the agent fleet (darwin-x64/freebsd-aarch64/win-2019) recovers, or merge based on the passing lanes.

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

Both of my earlier concerns (the MimeType.text fallback and the resolve() test coverage) have been addressed and the change looks correct, but since this introduces a new ownership invariant on Store.mime_type in core Blob/Body memory management, I'd like a human to sign off.

Extended reasoning...

Overview

Fixes a use-after-free where response.blob() aliased the same heap-allocated mime string between blob.content_type (owned, freed in Blob.deinit) and the refcounted Store.mime_type.value. Adds Store.mime_type_allocated: bool, a Store.setMimeType() helper that frees any prior allocation, and a free in Store.deinit. Two call sites in Body.zig (resolve's .getBlob arm and getBlobWithThisValue) now dupe the mime string into the Store when it's heap-allocated and route the MimeType.text fallback through setMimeType so the flag is always reset. ASAN-verified regression tests cover both code paths via subprocess.

Security risks

None introduced. The change strictly tightens memory ownership — it eliminates a UAF rather than adding one. No new untrusted-input parsing, no auth/crypto/permission surface.

Level of scrutiny

High. This is manual memory management in a hot, refcounted core-runtime path (Blob/Store). The PR introduces a new invariant (mime_type_allocatedStore.deinit frees mime_type.value) that all future writers to store.mime_type must respect. I verified the only remaining direct assignment outside setMimeType (Blob.fromURLSearchParams at Blob.zig:709) is on a freshly-created Store with a static registry value, so it's safe — but a maintainer who owns this area should confirm the invariant is acceptable going forward.

Other factors

  • Both of my prior inline comments (fallback flag reset; test not actually exercising resolve()) were addressed in c4c1af4 and 653364d respectively, and verified by the author against an unfixed build under ASAN.
  • CodeRabbit's concerns are resolved; the nitpick about further deduplication was reasonably declined to keep the change scoped.
  • The bug-hunting system found no issues on the current revision.
  • CI on the relevant test file is green across lanes; remaining failures are documented pre-existing flakes / agent-capacity outage unrelated to this change.
  • An alternative branch exists that transfers ownership to the Store instead of duping; either approach is sound and the author chose the more conservative dup-per-owner model.

@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as obsolete after the Zig→Rust port of webcore on main.

While rebasing onto current main I found that the implementation this PR patches has moved:

  • src/runtime/webcore/Body.zig / blob/Store.zig are no longer compiled — the built binary contains no references to Body.zig and the build system (cargo → libbun_rust.a) ignores edits to them. The live implementation is src/runtime/webcore/Body.rs / blob/Store.rs.

  • The Rust port already fixes this exact aliasing bug with the inverted ownership model. Body.rs (both the resolve .getBlob arm and getBlobWithThisValue) carries an explicit PORT NOTE:

    PORT NOTE: ownership reshape vs Zig. Zig's MimeType has no destructor so blob.content_type (freed via content_type_allocated) is the sole owner and store.mime_type aliases it. Rust MimeType.value is Cow (RAII), so we give the Store the owning Cow and let blob.content_type alias it (Blob holds a +1 on Store, alias valid for Blob's lifetime).

    The Store owns the mime string and frees it on drop; the Blob's alias sets content_type_allocated = false, so the slice() + GC scenario this PR reproduced can no longer read freed memory.

  • Verified empirically: the regression test from this PR passes against a current-main binary with no source changes (and the .zig edits here are dead code), so there is nothing left for this PR to fix and the test no longer distinguishes fixed from unfixed.

Thanks to the reviewers — the analysis here (aliasing between blob.content_type and store.mime_type.value) matches what the Rust port addressed.

@robobun robobun closed this May 24, 2026
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