-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Fix use-after-free in Bun.Transpiler async transform() parse errors
#30020
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
20 changes: 20 additions & 0 deletions
20
test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { expect, test } from "bun:test"; | ||
|
|
||
| test("async transform() with parse errors does not read freed arena memory", async () => { | ||
| const transpiler = new Bun.Transpiler(); | ||
|
|
||
| // Parse errors are allocated in a per-task arena that is freed when the | ||
| // worker thread finishes. Before the fix, the error text was read from | ||
| // that freed arena on the JS thread when rejecting the promise. | ||
| const results = await Promise.allSettled( | ||
| Array.from({ length: 64 }, () => transpiler.transform("const x = ;;;")), | ||
| ); | ||
|
|
||
| for (const result of results) { | ||
| expect(result.status).toBe("rejected"); | ||
| const reason = (result as PromiseRejectedResult).reason; | ||
| expect(reason.message).toBe("Unexpected ;"); | ||
| expect(reason.position?.line).toBe(1); | ||
| expect(reason.position?.lineText).toBe("const x = ;;;"); | ||
| } | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Minor:
appendToWithRecycledallocates aStringBuilderbuffer and anotes_bufinbun.default_allocatorto hold the cloned message text, butLog.deinit()only doesmsgs.clearAndFree()and never frees those buffers — so each asynctransform()parse error leaks the cloned error text. This is the same pre-existing pattern as every otherappendToWithRecycledcaller 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 givesLogproper ownership of cloned string buffers.Extended reasoning...
What the bug is
The new
deferblock inTransformTask.run()callsthis.log.appendToWithRecycled(&new_log, true)to deep-clone any parser/lexer error messages out of the per-task arena intobun.default_allocatorbefore the arena is freed. This correctly fixes the use-after-free. However, the buffers thatappendToWithRecycledallocates to hold the cloned text are never freed, so they leak.Code path
In
src/logger.zig,cloneToWithRecycled()(called byappendToWithRecycled) does two allocations againstother.msgs.allocator— which here isbun.default_allocatorbecausenew_logwas created withlogger.Log.init(bun.default_allocator):try string_builder.allocate(other.msgs.allocator);— one contiguous buffer holding all clonedtext,location.file,location.line_text, etc.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
Msgentries;Logitself keeps no separate handle to them.TransformTask.deinit()callsthis.log.deinit(), andLog.deinit()(logger.zig:840) is justlog.msgs.clearAndFree(). That frees theArrayList(Msg)backing array but does not callMsg.deinit()on the entries, and does not free theStringBuilderbuffer ornotes_buf. There is even an explicit TODO at logger.zig:846 noting thatdeinit"does not de-initialize the log".Why nothing else frees it
After
run()returns,then()runs on the JS thread and callsthis.log.toJS()→Msg.clone(), whichallocator.dupes the text a second time intodefault_allocatorfor theBuildMessage. So the first clone made byappendToWithRecycledis consumed only by being copied again, and is then orphaned whenthis.log.deinit()drops themsgsarray.Step-by-step example
transpiler.transform("const x = ;;;")schedules aTransformTask.run()createsarena, sets it as the transpiler allocator, parses, and the lexer pushes aMsgwhosedata.text = "Unexpected ;"andlocation.line_text = "const x = ;;;"are allocated inarena.deferfires:new_log = Log.init(default_allocator);appendToWithRecycled(&new_log, true)allocates a ~few-dozen-byteStringBuilderbuffer indefault_allocator, copies "Unexpected ;", the file path, and the line text into it, and rewritesnew_log.msgs.items[0]to point at those slices.this.log = new_log.arena.deinit()runs — fine, nothing points into it anymore (UAF fixed).then()→log.toJS()→Msg.clone()dupes the text again intodefault_allocatorfor theBuildMessage, thendeinit()runs.this.log.deinit()→msgs.clearAndFree()frees the 1-elementMsgarray. TheStringBuilderbuffer and (empty here, but in general)notes_buffrom 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 callstransform()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
Loghas no ownership model for message string content.appendToWithRecycledis 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 requiresLogto track owned string buffers (or forLog.deinit()to walkmsgsand freedata/notes), which is out of scope for a targeted UAF fix.Possible follow-up fix
Either (a) have
cloneToWithRecycledstash theStringBuilder.ptr[0..cap]andnotes_bufonLogsodeinit()can free them, or (b) makeLog.deinit()iteratemsgs.itemsand callmsg.deinit(log.msgs.allocator)beforeclearAndFree(). (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.