Skip to content

Fix use-after-free in Bun.Transpiler async transform() parse errors#30020

Closed
robobun wants to merge 2 commits into
mainfrom
farm/d517fa80/transpiler-transform-error-uaf
Closed

Fix use-after-free in Bun.Transpiler async transform() parse errors#30020
robobun wants to merge 2 commits into
mainfrom
farm/d517fa80/transpiler-transform-error-uaf

Conversation

@robobun

@robobun robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a use-after-free when new Bun.Transpiler().transform(code) (the async variant) is called with input that produces parse errors.

Root cause

TransformTask.run() executes on a worker thread and creates a MimallocArena for parsing, which is freed via defer arena.deinit() when run() returns. The transpiler allocator is set to this arena, so when the lexer/parser add error messages (e.g. via addRangeErrorstd.fmt.allocPrint(self.allocator, ...)), the message text lives in the arena.

The Log.msgs ArrayList itself is backed by bun.default_allocator, so it survives — but its entries point into the now-freed arena. When then() runs on the JS thread and calls this.log.toJS()Msg.clone()allocator.dupe(u8, text), it reads freed memory.

In release builds this manifests as garbage error messages (e.g. "" or arbitrary bytes instead of "Unexpected ;"). In ASAN builds it's a use-after-poison.

Fix

Before the arena is freed, deep-clone any log messages into bun.default_allocator via appendToWithRecycled so the text remains valid for then().

How did you verify your code works?

  • Added a regression test that fails on main (error message comes back as garbage) and passes with this fix.
  • Ran 30 iterations of the minimized repro under ASAN with no crashes.
  • Existing transpiler.test.js and transpiler-tsconfig-uaf.test.ts pass.

TransformTask.run() allocates a MimallocArena for parsing on the worker
thread and frees it when run() returns. Parse/lexer errors allocate their
message text via that arena, but the log outlives run() and is read by
then() on the JS thread when rejecting the promise. This caused the
rejection reason to contain freed memory (garbage text in release builds,
ASAN use-after-poison in debug builds).

Deep-clone log messages into default_allocator before the arena is freed.
@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:57 PM PT - Apr 30th, 2026

@autofix-ci[bot], your commit daaf253 has 1 failures in Build #49554 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30020

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

bun-30020 --bun

@coderabbitai

coderabbitai Bot commented Apr 30, 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: 4f28fc9e-0dc2-41eb-afd8-3263565d2ea0

📥 Commits

Reviewing files that changed from the base of the PR and between efe71ac and daaf253.

📒 Files selected for processing (1)
  • test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts

Walkthrough

Deferred cleanup was added to TransformTask.run to clone parser/lexer error messages into the longer-lived allocator when any log messages exist. A concurrent test was added to ensure transform() parse errors retain stable message content and positions across many simultaneous invocations.

Changes

Cohort / File(s) Summary
Transpiler Error Memory Management
src/bun.js/api/JSTranspiler.zig
Added a deferred cleanup that, if this.log.msgs is non-empty, clones the current logger.Log into bun.default_allocator via appendToWithRecycled and replaces this.log so parser/lexer error strings survive freeing the temporary arena.
Concurrent Transform Error Test
test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts
Added a test that issues many concurrent Bun.Transpiler().transform() calls on invalid input, awaits all settlements, and asserts each rejection contains the expected parse error message and stable position/line text.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main fix: addressing a use-after-free bug in the async transform() method when parsing errors occur, which directly matches the changeset.
Description check ✅ Passed The PR description follows the required template with both sections completed: 'What does this PR do?' provides detailed explanation of the bug and fix, and 'How did you verify your code works?' documents the verification steps taken.
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: 4/5 reviews remaining, refill in 12 minutes.

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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix use-after-free in Bun.Transpiler async transform() errors #29958 - Also fixes the use-after-free in Bun.Transpiler async transform() error handling by cloning log messages before the MimallocArena is freed

🤖 Generated with Claude Code

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29958, which already addresses this same UAF and has been reworked per review to avoid the StringBuilder leak that the appendToWithRecycled approach here would introduce.

@robobun robobun closed this Apr 30, 2026
Comment on lines +494 to +502
// Error messages added by the parser/lexer may contain text allocated
// in the arena. Deep-clone them into default_allocator before the arena
// is freed so they remain valid when then() reads them on the JS thread.
defer if (this.log.msgs.items.len > 0) {
var new_log = logger.Log.init(bun.default_allocator);
new_log.level = this.log.level;
bun.handleOom(this.log.appendToWithRecycled(&new_log, true));
this.log = new_log;
};

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.

🟡 Minor: appendToWithRecycled allocates a StringBuilder buffer and a notes_buf in bun.default_allocator to hold the cloned message text, but Log.deinit() only does msgs.clearAndFree() and never frees those buffers — so each async transform() parse error leaks the cloned error text. This is the same pre-existing pattern as every other appendToWithRecycled caller and trading a UAF for a small error-path leak is clearly the right call here; just flagging it as a candidate for a follow-up that gives Log proper ownership of cloned string buffers.

Extended reasoning...

What the bug is

The new defer block in TransformTask.run() calls this.log.appendToWithRecycled(&new_log, true) to deep-clone any parser/lexer error messages out of the per-task arena into bun.default_allocator before the arena is freed. This correctly fixes the use-after-free. However, the buffers that appendToWithRecycled allocates to hold the cloned text are never freed, so they leak.

Code path

In src/logger.zig, cloneToWithRecycled() (called by appendToWithRecycled) does two allocations against other.msgs.allocator — which here is bun.default_allocator because new_log was created with logger.Log.init(bun.default_allocator):

  • line 818: try string_builder.allocate(other.msgs.allocator); — one contiguous buffer holding all cloned text, location.file, location.line_text, etc.
  • line 819: var notes_buf = try other.msgs.allocator.alloc(Data, notes_count); — backing storage for cloned notes.

These buffers are only referenced via slices stored inside the cloned Msg entries; Log itself keeps no separate handle to them.

TransformTask.deinit() calls this.log.deinit(), and Log.deinit() (logger.zig:840) is just log.msgs.clearAndFree(). That frees the ArrayList(Msg) backing array but does not call Msg.deinit() on the entries, and does not free the StringBuilder buffer or notes_buf. There is even an explicit TODO at logger.zig:846 noting that deinit "does not de-initialize the log".

Why nothing else frees it

After run() returns, then() runs on the JS thread and calls this.log.toJS()Msg.clone(), which allocator.dupes the text a second time into default_allocator for the BuildMessage. So the first clone made by appendToWithRecycled is consumed only by being copied again, and is then orphaned when this.log.deinit() drops the msgs array.

Step-by-step example

  1. transpiler.transform("const x = ;;;") schedules a TransformTask.
  2. On the worker thread, run() creates arena, sets it as the transpiler allocator, parses, and the lexer pushes a Msg whose data.text = "Unexpected ;" and location.line_text = "const x = ;;;" are allocated in arena.
  3. The new defer fires: new_log = Log.init(default_allocator); appendToWithRecycled(&new_log, true) allocates a ~few-dozen-byte StringBuilder buffer in default_allocator, copies "Unexpected ;", the file path, and the line text into it, and rewrites new_log.msgs.items[0] to point at those slices. this.log = new_log.
  4. arena.deinit() runs — fine, nothing points into it anymore (UAF fixed).
  5. On the JS thread, then()log.toJS()Msg.clone() dupes the text again into default_allocator for the BuildMessage, then deinit() runs.
  6. this.log.deinit()msgs.clearAndFree() frees the 1-element Msg array. The StringBuilder buffer and (empty here, but in general) notes_buf from step 3 are never freed.

Repeat 64 times (as the regression test does) → 64 small leaks.

Impact

Small and bounded: it only triggers on the error path of async transform(), and each leak is roughly the size of the error message text + line text + file path (tens of bytes). For typical usage this is negligible. It could add up for long-running tooling that repeatedly calls transform() on broken input (e.g. a watch loop over a file with a syntax error), but it will not affect correctness.

Crucially, this PR replaces a use-after-free (a real memory-safety bug producing garbage error messages / ASAN crashes) with a small error-path leak — strictly an improvement.

Why this is a nit, not a blocker

Log has no ownership model for message string content. appendToWithRecycled is the canonical pattern in this codebase for moving log messages out of a dying arena, and every existing caller (e.g. BundleThread.zig) has the identical leak. This PR is following established convention; fixing it properly requires Log to track owned string buffers (or for Log.deinit() to walk msgs and free data/notes), which is out of scope for a targeted UAF fix.

Possible follow-up fix

Either (a) have cloneToWithRecycled stash the StringBuilder.ptr[0..cap] and notes_buf on Log so deinit() can free them, or (b) make Log.deinit() iterate msgs.items and call msg.deinit(log.msgs.allocator) before clearAndFree(). (b) is risky because many call sites push arena-backed or static text into logs, so (a) — explicit ownership of the clone buffers — is probably safer.

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