Repository navigation
macros: copy the JSON text of a returned Blob into the arena before parsing it (heap-use-after-free) - #46677
macros: copy the JSON text of a returned Blob into the arena before parsing it (heap-use-after-free)#46677robobun wants to merge 1 commit into
Conversation
The JSON parser leaves every escape-free string and key as a borrow of its source. For a JSON Blob or Response returned from a macro, the source was the Blob's store, and the store is freed when the Blob is collected. The object that the macro call was inlined as could then point at freed memory: a heap-use-after-free under ASAN, and the bytes of a later allocation in the output of a release build.
|
Status: ready for review. How to reproduce. Use a build without this change. // m.ts
const body = JSON.stringify({ key: Buffer.alloc(4000, "A").toString() });
export function blob() {
return new Blob([body], { type: "application/json" });
}
export function collect() {
Bun.gc(true);
const sameSize = [];
for (let i = 0; i < 200; i++) sameSize.push(new Blob([Buffer.alloc(body.length, "Z")]));
return sameSize.length;
}// index.ts
import { blob, collect } from "./m.ts" with { type: "macro" };
const value = blob();
const count = collect();
console.log(Object.keys(value), value.key === Buffer.alloc(4000, "A").toString(), count);bun index.ts
The PR body has a second repro that uses only |
|
Updated 10:43 PM PT - Oct 23rd, 2026
✅ @robobun, your commit ecf75df87a8f6b0085dd0d52bf7f8ffc5d7b7e33 passed in 🧪 To try this PR locally: bunx bun-pr 46677That installs a local version of the PR into your bun-46677 --bun |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Arena-backed parsing and regression coverage src/js_parser_jsc/Macro.rs, test/bundler/transpiler/macro-test.test.ts |
expr_from_blob copies JSON Blob bytes into the bump arena before initializing the parser Source. A subprocess test checks that Blob and Response results retain the expected parsed string after garbage collection and further allocations. |
Suggested reviewers: jarred-sumner
Priority:
Merge Risk: 
Minimal · up to ecf75
-
No actionable merge-blocking issue is identified; the change appears ready for normal merge checks.
Pre-merge checks: 
4
-
Full details: Description check
-
Explanation
The description follows the repository template with both required sections, "What does this PR do?" (Problem, Fix, Background, Downsides) and "How did you verify your code works?" (test added, repro results, measurements, suites run). It states the root cause, the fix, the new regression test, and the tradeoffs (extra arena allocation, binary size, RSS growth under --hot). The verification section is detailed, with repro tables, ASAN and release results, and the debug-build suite run. The description is thorough and mostly complete.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I checked the sibling paths in expr_from_blob and Run::coerce for the same borrowed-source hazard: the text, base64, JS-string and object-key arms already copy into bump (Macro.rs:845, 895, 1184, 1199), and parse_for_macro has this single caller, so the JSON arm was the only site handing Blob-store memory to rehome (json.rs:1083). alloc_slice_copy routes OOM through out_of_memory() like the neighbouring arena allocations, and an empty body short-circuits in parse_for_macro before any slice is retained.
Extended reasoning...
The diff adds one arena copy in the JSON arm of expr_from_blob in src/js_parser_jsc/Macro.rs and a subprocess regression test in test/bundler/transpiler/macro-test.test.ts; it touches native memory-safety code in the macro path but no auth, crypto, or input-validation surface. The fix matches the pattern of every sibling arm and the JSON parser's rehome confirms escape-free strings stay borrowed from the source, so the copy is the right layer. One inline finding about arena residency across re-transpiles is being posted, so a human should weigh it; this note records what else was examined.
|
|
||
| if is_json { | ||
| // The parsed strings borrow the source bytes; keep them in `bump`. | ||
| let bytes: &[u8] = bump.alloc_slice_copy(bytes); |
There was a problem hiding this comment.
🟡 (optional) Long-lived processes that re-run JSON-returning macros (for example bun --hot reloads) now keep every returned JSON body resident for the life of the transpiler, so RSS grows by one body per macro call and never comes back. src/js_parser_jsc/Macro.rs:1149 copies the whole body into MacroContext.bump, and that arena is created once at Macro.rs:239-240 and never reset() anywhere in src/. On the base branch the text lived in the Blob store and was released on GC (the UAF this PR fixes). Fix: copy only what the AST keeps (make rehome at src/parsers/json.rs:1083 copy source-borrowed strings into bump) or reset/free the arena once the produced Expr has been printed; the sibling text arm at Macro.rs:1184 has the same lifetime but copies only the final string.
Why this was flagged
A project whose macro returns fetch(url) JSON (the documented pattern), run under bun --hot or any mode that keeps vm.transpiler alive across re-transpiles, calls the macro again on every reload. Each call reaches expr_from_blob via the Blob arm at src/js_parser_jsc/Macro.rs:747 and the new line Macro.rs:1149 allocates a full copy of the body in bump. bump is MacroContext.bump (Macro.rs:72), lazily created at Macro.rs:239-240; a grep of src/ shows no bump.reset or take on it outside benches, so it is only freed when the Transpiler drops (src/bundler/transpiler.rs:107). The base branch held none of this text in the arena (the Blob store was freed on GC, which is the UAF), so for a 5 MB JSON body and 100 reloads the process retains ~500 MB more than before. Remedy: copy only the borrowed strings into the arena, or scope the copy to the lifetime of the produced Expr.
Verification: nit (pre-existing route; acknowledged in diff: the PR description's "Downsides" states "its text stays in the arena", which is accurate). src/js_parser_jsc/Macro.rs:1149 let bytes: &[u8] = bump.alloc_slice_copy(bytes); copies the whole body into MacroContext.bump. So each such macro call retains one body-sized allocation for the transpiler's lifetime.
There was a problem hiding this comment.
I measured it. bun --hot, an entry file whose macro returns a 16 MiB body, RSS in MB after each of 12 reloads (release builds, linux x64):
| build | the macro returns | RSS |
|---|---|---|
| main | a JSON body | 80, flat |
| this PR | a JSON body | 95 to 288, +16 per reload |
| main | a text body | 96 to 289, +16 per reload |
| main | a string | 111 to 496, +32 per reload |
The JSON arm now costs what the text arm costs on main. The flat line on main is the bug itself: the parsed strings point into the Blob's store, and the store is freed.
A copy of only the strings in rehome saves the structural bytes of the JSON and keeps the growth. The growth comes from the lifetime of MacroContext.bump, and no arm can shorten that alone. The arena is never reset, and for the main-thread transpiler it lives as long as the process. A module that is transpiled on the work pool gets a context for each job, and the job frees it. A fix has to free what a macro call allocated once its file is printed, for every arm. That is a separate change from this fix.
The Downsides section of the description now has these numbers.
Problem
ResponseorBlobthat a macro returns can be inlined from freed memory.bun buildexits 0 and writes the bytes of a later allocation. ASAN reportsheap-use-after-freeinbun_js_parser::fold::e_string_eql_bytes.return fetch(url)is enough: main can write another response's body,var cfg = { "\x00\x00\x00": "SECRET_TOKEN=hunter2;....expr_from_blob(src/js_parser_jsc/Macro.rs:1148) parses the JSON in place in the Blob's store. The parser keeps escape-free strings as borrows of its source (src/parsers/json.rs:1083). The store is freed when the Blob is collected.Fix
test/bundler/transpiler/macro-test.test.ts. Main prints[["ZZZ"],false,...]. The fetch repro is correct 3 of 3.Background
Run::coerceturns the value a macro returns into AST nodes. For aBlob,expr_from_blobpicks JSON, text or a base64 data URL by MIME type.MacroContext.bump) lives as long as the transpiler. Considered a rooted Blob: the AST has no owner that could release it. macros: run every macro on one dedicated VM thread #40059 has this same line.Downsides
bun --hot, a 16 MiB JSON body adds 16 MB of RSS per reload, as a text body does on main. Main is flat for JSON because its strings point into freed memory.Notes
Repro with
fetchonly (noBun.gc, noBlobconstructor). A local server returns{"key":"AAAA…","apiBase":…}on/and a second JSON body on/private.bun build ./index.ts --target=bun --outfile=out/b.js, release builds, linux x64:var cfg =in the bundle{ key: "AAAA…3 of 3The test uses
Bun.gc(true)and 200 same-size allocations in a second macro, so the reuse does not depend on timing. The ASAN debug build of main aborts on it. A release build of main prints theZbytes.Measurements (release, main 6292c65 against this PR, linux x64):
llvm-size: text 107,688,070 to 107,688,326.expr_from_blob2,493 to 2,570 bytes.expr_from_blobfornew Blob(['{"banner":"BANNER-TEXT-16-B"}'], { type: "application/json" }), counted with gdb breakpoints on the mimalloc entry points over 101 call sites: 11 to 12 per call. Bytes that stay allocated: 144 to 173.Arena residency under
bun --hot(release builds, linux x64). The entry file calls a macro that returns a 16 MiB body, and the file is rewritten 12 times. RSS in MB after each load:MacroContext.bumpis never reset. For the main-thread transpiler it lives as long as the process, so this is about the entry file and modules that the main thread transpiles. A module that is transpiled on the work pool gets a context for each job, and the job frees it. To free a macro result once its file is printed is a change for every arm, and it is not in this PR.Suites run on the debug build:
test/bundler/transpiler/macro-test.test.ts(35 pass).Related
Macro.rsand carries the same copy. It has no commit since 2026-08-25.expr_from_blob. Any merge order works, with a small rebase.Bun.file()is inlined as empty).[auto-merge] gate passed · iteration 0 · 2 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file