From efe71aca48b7e4089d4e9440224cc68e36a3b4b7 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 30 Apr 2026 23:49:08 +0000 Subject: [PATCH 1/2] Fix use-after-free in Bun.Transpiler async transform() parse errors 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. --- src/bun.js/api/JSTranspiler.zig | 10 ++++++++++ .../transpiler-transform-error-uaf.test.ts | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts diff --git a/src/bun.js/api/JSTranspiler.zig b/src/bun.js/api/JSTranspiler.zig index ea61d9e1aba8..0de07602b1b3 100644 --- a/src/bun.js/api/JSTranspiler.zig +++ b/src/bun.js/api/JSTranspiler.zig @@ -491,6 +491,16 @@ pub const TransformTask = struct { var arena = MimallocArena.init(); defer arena.deinit(); + // 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; + }; + const allocator = arena.allocator(); var ast_memory_allocator = bun.handleOom(allocator.create(JSAst.ASTMemoryAllocator)); var ast_scope = ast_memory_allocator.enter(allocator); diff --git a/test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts new file mode 100644 index 000000000000..1b47ce2cafa4 --- /dev/null +++ b/test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts @@ -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 = ;;;"); + } +}); From daaf253b5acb6c6e4604314b8bd293a68dfefe3c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:51:24 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts index 1b47ce2cafa4..537b076ab256 100644 --- a/test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts +++ b/test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts @@ -6,9 +6,7 @@ test("async transform() with parse errors does not read freed arena memory", asy // 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 = ;;;")), - ); + const results = await Promise.allSettled(Array.from({ length: 64 }, () => transpiler.transform("const x = ;;;"))); for (const result of results) { expect(result.status).toBe("rejected");