-
Notifications
You must be signed in to change notification settings - Fork 5k
Fix crash in Bun.file().writer() when options has non-string path
#30294
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -207,6 +207,18 @@ it("write result is not cumulative", async () => { | |||||||||||||||||||||||||||||||||||||||||||||
| await util.promisify(fs.close)(fd); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it("writer() does not crash when options object has a non-string 'path' property", async () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const x = tmpdirSync(); | ||||||||||||||||||||||||||||||||||||||||||||||
| const dest = path.join(x, "test.txt"); | ||||||||||||||||||||||||||||||||||||||||||||||
| const file = Bun.file(dest); | ||||||||||||||||||||||||||||||||||||||||||||||
| const options = {}; | ||||||||||||||||||||||||||||||||||||||||||||||
| Object.defineProperty(options, "path", { enumerable: true, value: Uint32Array }); | ||||||||||||||||||||||||||||||||||||||||||||||
| const writer = file.writer(options); | ||||||||||||||||||||||||||||||||||||||||||||||
| await writer.write("hello"); | ||||||||||||||||||||||||||||||||||||||||||||||
| await writer.end(); | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(await Bun.file(dest).text()).toBe("hello"); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+210
to
+220
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Use Line 211 introduces a new ♻️ Proposed change-import { fileDescriptorLeakChecker, isPosix, isWindows, tmpdirSync } from "harness";
+import { fileDescriptorLeakChecker, isPosix, isWindows, tempDir, tmpdirSync } from "harness";
@@
it("writer() does not crash when options object has a non-string 'path' property", async () => {
- const x = tmpdirSync();
- const dest = path.join(x, "test.txt");
+ using x = tempDir("filesink-writer-non-string-path");
+ const dest = path.join(x, "test.txt");
const file = Bun.file(dest);
const options = {};
Object.defineProperty(options, "path", { enumerable: true, value: Uint32Array });
const writer = file.writer(options);
await writer.write("hello");
await writer.end();
expect(await Bun.file(dest).text()).toBe("hello");
});As per coding guidelines: 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Add a companion regression case for invalid non-integer This PR fixes both invalid 🧪 Suggested companion test+it("writer() does not crash when options object has a non-integer 'fd' property", async () => {
+ using x = tempDir("filesink-writer-non-int-fd");
+ const dest = path.join(x, "test.txt");
+ const file = Bun.file(dest);
+ const options = {};
+ Object.defineProperty(options, "fd", { enumerable: true, value: "not-an-int" });
+ const writer = file.writer(options);
+ await writer.write("hello");
+ await writer.end();
+ expect(await Bun.file(dest).text()).toBe("hello");
+});🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (isWindows) { | ||||||||||||||||||||||||||||||||||||||||||||||
| it("ENOENT, Windows", () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(() => Bun.file("A:\\this-does-not-exist.txt").writer()).toThrow( | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
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.
🟣 Pre-existing, but since this PR is hardening this exact call against malformed options: if a getter on the options object throws (e.g.
Object.defineProperty(opts, 'highWaterMark', {get(){throw 0}})), thetryhere propagateserror.JSErrorand the heap-allocatedsinkfrom line 2954 is never released. Addingerrdefer sink.deref();right after theFileSink.init(...)call would close the leak.Extended reasoning...
What the bug is
In
Blob.getWriter(src/runtime/webcore/Blob.zig:2954),sinkis heap-allocated viajsc.WebCore.FileSink.init(bun.invalid_fd, ...).FileSink.init(FileSink.zig:553-563) callsbun.new(FileSink, ...), initializesref_count = 1, and incrementslive_count. Twenty-three lines later, line 2977 doestry jsc.WebCore.streams.Start.fromJSWithTag(globalThis, arguments[0], .FileSink). If thattrypropagates, there is noerrdefer sink.deref()in scope — the only cleanup registered between allocation and thetryisdefer input_path.deinit()at line 2968. TheFileSink(and its writer/buffers) leak for the rest of the process, andlive_countis never decremented.Code path that triggers it
fromJSWithTag(streams.zig:70-143) returnsbun.JSError!Start. The.FileSinkbranch callstry value.fastGet(globalThis, .highWaterMark),try value.fastGet(globalThis, .path),try value.getTruthy(globalThis, "fd"), andtry path.toSlice(...). Any of these propagateerror.JSErrorif the user-supplied options object has a throwing getter or a Proxy trap that throws. That error propagates straight out ofgetWritervia thetryat line 2977.Why nothing catches it
All other error paths in this function explicitly call
sink.deref()before returning (e.g. lines 2944, 2987), but those are reached viaswitchon a result value, not via Zig error propagation. The onetryin this region has no matchingerrdefer. Thesinkis not yet attached to a JS wrapper (sink.toJS(globalThis)only happens at line 2993), so GC cannot reclaim it either.Step-by-step proof
const opts = {}; Object.defineProperty(opts, 'highWaterMark', { get() { throw new Error('boom'); } });Bun.file('/tmp/x').writer(opts);getWriterreaches line 2954 →sink = FileSink.init(...)heap-allocates,ref_count = 1,live_count++.input_pathis built and adefer input_path.deinit()is registered.arguments[0].isObject()is true.fromJSWithTagcallsopts.highWaterMarkgetter → throws → returnserror.JSError.trypropagates.defer input_path.deinit()runs (path is freed). Noerrdeferforsinkexists.sinkleaks.fileSinkInternals.liveCount()unboundedly.Impact
Per-call heap leak of a
FileSinkstruct plus its embedded writer state. Reachable only with adversarial/unusual options objects (throwing getters / Proxy traps), so it is not a correctness problem for normal code, but it is exactly the class of fuzzer-discovered malformed-options input this PR is hardening against — Fuzzilli will hit this with the same generator that produced the non-stringpathcrash.Fix
Add one line immediately after line 2954:
This is safe because the only normal-return path after this point is
return sink.toJS(globalThis)at line 2993, which does not go througherrdefer, and the existing explicitsink.deref()at line 2987 returns viaglobalThis.throwValue(...)which itself returnserror.JSError— but that path already deref'd once and then theerrdeferwould deref again. To avoid the double-deref on thesink.starterror path, either drop the explicitsink.deref()at line 2987 (letting theerrdeferhandle it) or scope theerrdefermore tightly around just thefromJSWithTagcall. The simplest correct change is:Relationship to this PR
The
trypredates this PR, so the leak is pre-existing. However, the PR rewrites this exact line to harden against fuzzer-found malformed options, and a throwing getter is the immediately-adjacent fuzzer case on the same property accesses, so it seems worth closing here while the author is already touching the line.