From c6f8b27d0ef0ae59d62e3ec8c368376893742873 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 11 May 2026 00:07:46 +0000 Subject: [PATCH 1/2] Fix use-after-free in Bun.Transpiler async transform() error messages TransformTask.run() uses a MimallocArena for parsing that is destroyed when run() returns on the worker thread. Parse error message text is allocated from that arena. When then() later runs on the JS thread and converts the log to a BuildMessage, it reads freed memory. Collect messages in a local arena-backed log and deep-copy them into the task's persistent log (backed by bun.default_allocator) before the arena is destroyed. --- src/runtime/api/JSTranspiler.zig | 11 +++++- .../transpiler-async-error-uaf.test.ts | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 test/js/bun/transpiler/transpiler-async-error-uaf.test.ts diff --git a/src/runtime/api/JSTranspiler.zig b/src/runtime/api/JSTranspiler.zig index 7d231902efe..e2211dc664a 100644 --- a/src/runtime/api/JSTranspiler.zig +++ b/src/runtime/api/JSTranspiler.zig @@ -500,9 +500,16 @@ pub const TransformTask = struct { var ast_scope = ast_memory_allocator.enter(allocator); defer ast_scope.exit(); + // The parser allocates error message text using the transpiler's + // allocator (the arena above). Collect messages in a local log and + // deep-copy them into `this.log` before the arena is destroyed so + // `then()` on the JS thread does not read freed memory. + var local_log = logger.Log.init(allocator); + local_log.level = this.log.level; + defer bun.handleOom(local_log.appendToWithRecycled(&this.log, true)); + this.transpiler.setAllocator(allocator); - this.transpiler.setLog(&this.log); - this.log.msgs.allocator = bun.default_allocator; + this.transpiler.setLog(&local_log); const jsx = if (this.tsconfig != null) this.tsconfig.?.mergeJSX(this.transpiler.options.jsx) diff --git a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts new file mode 100644 index 00000000000..b95bd8275a3 --- /dev/null +++ b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; + +describe("Transpiler async transform() error lifetime", () => { + test.concurrent("concurrent async transform() parse errors do not read freed memory", async () => { + const transpiler = new Bun.Transpiler(); + const promises: Promise[] = []; + for (let i = 0; i < 50; i++) { + promises.push(transpiler.transform("const x = @@@", "js").then( + () => null, + e => e, + )); + } + const results = await Promise.all(promises); + for (const r of results) { + expect(r).toBeInstanceOf(BuildMessage); + const msg = r as BuildMessage; + expect(msg.message).toBe('Expected identifier but found "@"'); + expect(msg.position).toEqual({ + lineText: "const x = @@@", + file: "input.js", + namespace: "file", + line: 1, + column: 12, + length: 1, + offset: 11, + }); + } + }); + + test.concurrent("async transform() rejects with a usable BuildMessage after arena is freed", async () => { + const transpiler = new Bun.Transpiler(); + const err = await transpiler.transform("1 + ", "js").then( + () => null, + e => e, + ); + expect(err).toBeInstanceOf(BuildMessage); + expect((err as BuildMessage).message).toBe("Unexpected end of file"); + }); +}); From d20758fdaa81b784ce78696211db311b1b30a02b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 00:10:01 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- .../bun/transpiler/transpiler-async-error-uaf.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts index b95bd8275a3..fd69e01a3f8 100644 --- a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts +++ b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts @@ -5,10 +5,12 @@ describe("Transpiler async transform() error lifetime", () => { const transpiler = new Bun.Transpiler(); const promises: Promise[] = []; for (let i = 0; i < 50; i++) { - promises.push(transpiler.transform("const x = @@@", "js").then( - () => null, - e => e, - )); + promises.push( + transpiler.transform("const x = @@@", "js").then( + () => null, + e => e, + ), + ); } const results = await Promise.all(promises); for (const r of results) {