Skip to content

webcore/Body: don't alias heap-allocated mime_type into shared Store#30260

Closed
robobun wants to merge 2 commits into
mainfrom
farm/b9ad935d/fix-blob-store-mime-uaf
Closed

webcore/Body: don't alias heap-allocated mime_type into shared Store#30260
robobun wants to merge 2 commits into
mainfrom
farm/b9ad935d/fix-blob-store-mime-uaf

Conversation

@robobun

@robobun robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a use-after-free in Response#blob() / Request#blob() found by fuzzing.

MimeType.init() heap-allocates its .value string when the Content-Type isn't one of the recognized static entries (e.g. "application/javascript"). The body .blob() path stored that heap pointer in both blob.content_type (owned — freed by Blob.deinit) and store.mime_type (borrowed — never freed by Store.deinit).

When another Blob shares the same store (via .slice()) and the original Blob is GC'd, reading slice.type falls through to store.mime_type.value and reads freed memory.

const response = new Response("hi", { headers: { "content-type": "application/javascript" } });
const blob = await response.blob();
const slice = blob.slice();
// ... original blob gets GC'd ...
slice.type; // use-after-free

ASAN stack:

READ of size 22 at 0x7b9cdedb00c0 thread T0
    #0 __asan_memcpy
    #1 Zig::toStringCopy(ZigString) helpers.h:217
    #2 ZigString__toValueGC bindings.cpp:3507
    #3 bun.js.bindings.ZigString.ZigString.toJS
    #4 bun.js.webcore.Blob.getType Blob.zig:3121

How did you verify your code works?

New regression test in test/js/web/fetch/blob.test.ts reads garbage / ASAN-aborts without the fix, returns a valid string with it.

MimeType.init() heap-allocates its value string when the content type
isn't one of the recognized static entries (e.g. "application/javascript").
Response#blob() / Request#blob() stored that heap pointer in both
blob.content_type (owned, freed by Blob.deinit) and store.mime_type
(borrowed, never freed by Store.deinit). When another Blob shares the
store via slice() and the original is GC'd, reading slice.type follows
the dangling store.mime_type.value.

Only copy the mime into the store when it points at static memory.
@github-actions github-actions Bot added the claude label May 4, 2026
@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:31 PM PT - May 4th, 2026

@robobun, your commit 14cf8d4 has 2 failures in Build #51489 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30260

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

bun-30260 --bun

@coderabbitai

coderabbitai Bot commented May 4, 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: 51267fb9-83e5-4809-bff8-e6b7412945ee

📥 Commits

Reviewing files that changed from the base of the PR and between 14cf8d4 and dc555de.

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

Walkthrough

Fixes blob MIME-type propagation: store MIME assignment now happens only when blob.store is non-null and the MimeType was not allocated. Adds a regression test that checks a sliced blob's type after forced GC to avoid reading freed MIME storage. (49 words)

Changes

Blob MIME Type Assignment Safety

Layer / File(s) Summary
Core Fix
src/runtime/webcore/Body.zig
Guard for blob.store.?.mime_type assignment changed from if (blob.store != null) to if (blob.store != null and !allocated) in both Body.Value.resolve and Mixin.getBlobWithThisValue.
Regression Test
test/js/web/fetch/blob.test.ts
New test (Response#blob() slice().type doesn't read freed store.mime_type after GC) spawns a subprocess that creates a Response with content-type, obtains Blob and slice(), forces GC, and prints slice.type; parent asserts clean exit, empty stderr, and stdout "" or "application/javascript".
Manifest
build.zig.zon, package.json
Manifest entries touched (listed in diffs) to include the new test changes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: preventing aliasing of heap-allocated mime_type into the shared Store to fix the use-after-free bug.
Description check ✅ Passed The description covers both required template sections: it explains what the PR does (fixes use-after-free) and how it was verified (new regression test).
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.


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

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. blob: stop aliasing store.mime_type.value with blob.content_type in response.blob() #29947 - Fixes the same use-after-free bug where heap-allocated store.mime_type.value is aliased with blob.content_type in Body.zig, causing UAF when the original Blob is GC'd and a slice reads the freed pointer

🤖 Generated with Claude Code

@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29947, which takes a more complete approach (gives Store its own copy so slice.type is preserved).

@robobun robobun closed this May 4, 2026
@robobun robobun deleted the farm/b9ad935d/fix-blob-store-mime-uaf branch May 4, 2026 22:27
Comment on lines +1443 to 1445
if (blob.store != null and !allocated) {
blob.store.?.mime_type = mimeType;
}

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.

🟡 This makes slice().type inconsistent: it now inherits the parent's type only when the Content-Type happens to match one of the ~7 hardcoded static entries in MimeType.init (e.g. text/plain), and returns "" for everything else (e.g. application/javascript). Per the W3C File API spec slice() without a contentType argument should return type === "", so the empty case is actually correct — but consider dropping the store.mime_type assignment entirely (and at line 767) so behavior is consistent rather than depending on an internal MIME-type list. Not a blocker for the UAF fix.

Extended reasoning...

What changed

The fix changes if (blob.store != null) to if (blob.store != null and !allocated), so store.mime_type is now only populated when MimeType.init() returns a static (non-heap-allocated) entry. This is the right call for safety — the heap-allocated string is owned by blob.content_type and freed by Blob.deinit, so aliasing it into the shared Store was the UAF being fixed. But it leaves store.mime_type in an inconsistent state that leaks an internal implementation detail to user code.

How it manifests

MimeType.init (src/http_types/MimeType.zig:260-360) returns a static constant with allocated=false only for a small hardcoded set: text/plain, text/html, text/css, text/javascript, application/json, application/wasm, application/octet-stream. Anything else — application/javascript, image/png, text/csv, custom types — falls through to the heap-allocating path with allocated=true.

When you call Blob#slice() with no contentType argument, getSliceFrom (Blob.zig:3009) only copies the parent's content_type into the slice when !this.content_type_allocated, so for allocated types the slice's own content_type is "". Blob.getType (Blob.zig:3137-3148) then falls through to store.mime_type.value. After this PR that fallback is populated for static types but stays .none (value "") for allocated ones.

Step-by-step proof

Static type:

const r = new Response('hi', { headers: { 'content-type': 'text/plain' } });
const b = await r.blob();
b.slice().type;  // 'text/plain;charset=utf-8'

MimeType.init('text/plain') → returns static MimeType.text, allocated=false!allocated is true → store.mime_type = MimeType.textgetType returns it.

Allocated type:

const r = new Response('hi', { headers: { 'content-type': 'application/javascript' } });
const b = await r.blob();
b.slice().type;  // ''

MimeType.init('application/javascript') → heap-allocates, allocated=true!allocated is false → store.mime_type stays .none (value "") → getType returns "".

So whether a slice inherits its parent's type depends purely on whether the Content-Type string is in an internal hardcoded list — which is surprising and not something users can predict.

Why this isn't a blocker

  1. Per the W3C File API spec, slice() with no contentType argument should return a Blob with type === "". So the allocated case is now spec-correct, and the static case is the (pre-existing) deviation.
  2. The "consistent" pre-PR behavior only existed because of the UAF being fixed — it wasn't a designed guarantee, it was UB that happened to look right while the parent blob was alive.
  3. getSliceFrom already only inherits content_type when !content_type_allocated, so this PR makes store.mime_type consistent with the existing slice-level split rather than inventing a new one.
  4. The new test in this PR already accepts both "" and "application/javascript" — only "" will ever be produced after this fix, which the assertion handles fine.

Suggested follow-up

For consistency, either:

  • (a) Drop the store.mime_type assignment entirely at both Body.zig:767 and Body.zig:1443. This makes slice().type uniformly "" (spec-compliant) regardless of Content-Type.
  • (b) Deep-copy the allocated string into the Store with proper ownership and free it in Store.deinit.

Option (a) is simpler and more correct per spec. Either way, this is a minor follow-up — the UAF fix itself is good.

Comment thread test/js/web/fetch/blob.test.ts Outdated
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr).toBe("");
expect(["\"\"", "\"application/javascript\""]).toContain(stdout);

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.

🟡 The assertion expect(["\"\"", "\"application/javascript\""]).toContain(stdout) accepts the buggy UAF output as valid. After this fix, slice.type is deterministically "" (since store.mime_type stays .none); the only way to get "application/javascript" here is the freed content_type allocation being read back before it's overwritten — exactly the regression this test guards against. Tighten to expect(stdout).toBe('""') so non-ASAN builds also fail if the UAF returns.

Extended reasoning...

What the issue is

The new regression test asserts:

expect(["\"\"", "\"application/javascript\""]).toContain(stdout);

This accepts two outputs as passing. But after the fix in this PR, only one of them — "" — is actually reachable. The other accepted value, "application/javascript", is precisely the symptom of the use-after-free this test is meant to catch on builds that don't poison freed memory.

Why "" is the only correct post-fix output

Walking the code path for new Response("hi", { headers: { "content-type": "application/javascript" } })await response.blob()blob.slice()slice.type:

  1. MimeType.init("application/javascript", alloc, &allocated) hits the "application".len branch in src/http_types/MimeType.zig:276-295. It checks for json, geo+json, octet-stream, and wasm — none match javascript — so it falls through and heap-allocates the lowercased copy, setting allocated = true.
  2. After this PR's fix, if (blob.store != null and !allocated) is false (because allocated == true), so store.mime_type is not assigned. It stays at the Store struct default mime_type: MimeType = .none (Store.zig:5), whose .value is "" (MimeType.zig:239).
  3. blob.slice() with no contentType argument goes through getSliceFrom (Blob.zig:3009), which only inherits the parent's content_type when !this.content_type_allocated. Here it is allocated, so the slice's content_type is left empty.
  4. slice.type calls Blob.getType (Blob.zig:3137-3146). With content_type.len == 0, it falls through to store.mime_type.value, which is "".

So the spawned subprocess must write JSON.stringify("")'""'. There is no code path in the fixed build that produces "application/javascript" for slice.type.

Why accepting "application/javascript" is harmful

The only way slice.type becomes "application/javascript" here is the pre-fix bug: store.mime_type.value aliases the heap-allocated blob.content_type, the original blob is GC'd and frees it, and getType reads the freed-but-not-yet-overwritten 22 bytes back out. On ASAN builds this aborts (caught by exitCode/stderr), but on release/non-ASAN builds the freed slot frequently still contains the original bytes, so the subprocess prints "application/javascript" and exits 0 — and the test passes even though the UAF has regressed.

By including "\"application/javascript\"" in the accepted set, the assertion explicitly green-lights the buggy output. The test only functions as a regression guard on ASAN CI; on every other build configuration it would silently pass with the bug present.

Step-by-step proof

Step Fixed build Regressed build (no ASAN)
MimeType.init allocated=true, value heap-alloc'd same
store.mime_type left at .none (value "") aliased to heap pointer
original blob GC'd frees content_type frees content_type (store still points at it)
slice.type reads store.mime_type.value = "" freed bytes ≈ "application/javascript"
stdout '""' '"application/javascript"'
Current assertion ✅ passes passes (bug undetected)
toBe('""') ✅ passes ❌ fails (bug caught)

Fix

expect(stdout).toBe('""');

This is a test-quality nit rather than a runtime bug — ASAN CI would still catch a regression via the crash — but tightening it costs nothing and makes the test meaningful on release builds too.

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