Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions src/runtime/webcore/S3Client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,11 @@
}
return globalThis.throwInvalidArguments("Expected a path", .{});
};
errdefer path.deinit();
var path_consumed = false;
errdefer if (!path_consumed) path.deinit();
const options = args.nextEat();
var blob = Blob.new(try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer));
path_consumed = true;
return blob.toJS(globalThis);
}

Expand All @@ -151,10 +153,12 @@
}
return globalThis.throwInvalidArguments("Expected a path to presign", .{});
};
errdefer path.deinit();
var path_consumed = false;
errdefer if (!path_consumed) path.deinit();

const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
path_consumed = true;

Check failure on line 161 in src/runtime/webcore/S3Client.zig

View check run for this annotation

Claude / Claude Code Review

path_consumed set too late: double-free still possible if construct fn throws after initS3

The `path_consumed` flag is set after `constructS3FileWithS3CredentialsAndOptions` returns, but ownership of `path` actually transfers *inside* that function at the `Blob.Store.initS3*` call — and there is still fallible code after it (`try opts.getTruthyComptime(globalObject, "type")` / `try file_type.toSlice(...)`). If that post-`initS3` read throws, `errdefer store.deinit()` frees the store, the error propagates with `path_consumed` still `false`, and the caller's `errdefer` derefs the alread
Comment on lines +156 to +161

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.

🔴 The path_consumed flag is set after constructS3FileWithS3CredentialsAndOptions returns, but ownership of path actually transfers inside that function at the Blob.Store.initS3* call — and there is still fallible code after it (try opts.getTruthyComptime(globalObject, "type") / try file_type.toSlice(...)). If that post-initS3 read throws, errdefer store.deinit() frees the store, the error propagates with path_consumed still false, and the caller's errdefer derefs the already-released original WTFStringImpl — the same double-free this PR is fixing. Consider passing path by pointer and clearing it right after initS3, or hoisting the "type" handling before store creation; this applies to every call site touched in S3Client.zig and S3File.zig.

Extended reasoning...

What the bug is

This PR's fix moves the ownership boundary to "after constructS3FileWithS3CredentialsAndOptions / constructS3FileInternalStore returns successfully". But the real ownership-transfer point is inside those functions, at the Blob.Store.initS3 / initS3WithReferencedCredentials call. Between that transfer point and the function's successful return, there is still fallible JS-visible code. If that code throws, we end up in exactly the state the PR set out to fix: the store has consumed the caller's path refs, yet the caller's errdefer if (!path_consumed) path.deinit() still fires.

The specific code path

In constructS3FileWithS3CredentialsAndOptions (and the near-identical constructS3FileWithS3Credentials):

  1. Blob.Store.initS3* is called (Store.zig:68-71 / 97-100). It takes pathlike by value, copies it locally, and calls path.toThreadSafe(). For .slice_with_underlying_string this reaches BunString__toThreadSafe (BunString.cpp:211-224), which installs an isolated copy and calls existing->deref() on the original WTFStringImpl. The caller's path variable now points at an impl whose refs have been released — this is the PR's own stated premise.
  2. errdefer store.deinit() is registered.
  3. After that, try opts.getTruthyComptime(globalObject, "type") and try file_type.toSlice(...) run. These perform a JS property access on a user-supplied options object and can throw.

If step 3 throws: errdefer store.deinit() frees the store (and its thread-safe copy of the path), the error propagates back to e.g. S3Client.presign, and since the construct function never returned, path_consumed is still false. The caller's errdefer if (!path_consumed) path.deinit() then derefs the already-released original impl → hasAtLeastOneRef() assertion in debug / double-free in release.

Why existing code doesn't prevent it

The PR's flag is set on the line after the try, so it cannot observe partial progress inside the callee. The callee's own errdefer store.deinit() correctly cleans up the store but does nothing to tell the caller that the original path refs were already consumed. Note that getCredentialsWithOptions (credentials_jsc.zig:203) also reads "type" once before initS3, so a plain throwing getter would fail early (which is handled correctly); a stateful getter or Proxy that succeeds the first time and throws the second is what reaches the post-initS3 read.

Step-by-step proof

let n = 0;
new Bun.S3Client({}).presign("abc", {
  accessKeyId: "a", secretAccessKey: "b", bucket: "c", endpoint: "http://localhost",
  get type() { if (n++ > 0) throw new Error("boom"); return "text/plain"; }
});
  1. S3Client.presign parses path = .slice_with_underlying_string for "abc", sets path_consumed = false, registers errdefer if (!path_consumed) path.deinit().
  2. Calls constructS3FileWithS3CredentialsAndOptions. getCredentialsWithOptions reads type (n=0 → returns "text/plain", n becomes 1).
  3. Blob.Store.initS3WithReferencedCredentials(path, …) runs → toThreadSafe() derefs the original impl behind the caller's path.
  4. opts.getTruthyComptime(globalObject, "type") reads type again (n=1 → throws). error.JSError is returned.
  5. errdefer store.deinit() frees the store; error propagates to presign.
  6. path_consumed is still falsepath.deinit() runs → derefs the already-released impl → crash.

Impact and scope

This is the same crash class (Fuzzilli fingerprint 1ebbda306723eeb6) the PR targets, just on a window the patch doesn't cover. Fuzzilli routinely generates exactly these adversarial getters/Proxies, so this remaining window is likely to be rediscovered. It applies to every call site this PR modified: S3Client.{file,presign,exists,size,stat,write,unlink} and S3File.{presign,unlink,write,size,exists,stat}.

How to fix

Move the ownership signal to the actual transfer point. Two straightforward options:

  • Have constructS3FileWithS3CredentialsAndOptions / constructS3FileWithS3Credentials take path: *jsc.Node.PathLike and set path.* = .{ .string = bun.PathString.empty } (or equivalent) immediately after initS3* returns. Callers can then unconditionally errdefer path.deinit() since deinit on an empty path is a no-op.
  • Or hoist the "type" handling to before initS3*, so there is no fallible code between ownership transfer and return.

defer blob.detach();
return S3File.getPresignUrlFrom(&blob, globalThis, options);
}
Expand All @@ -169,9 +173,11 @@
}
return globalThis.throwInvalidArguments("Expected a path to check if it exists", .{});
};
errdefer path.deinit();
var path_consumed = false;
errdefer if (!path_consumed) path.deinit();
const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
path_consumed = true;
defer blob.detach();
return S3File.S3BlobStatTask.exists(globalThis, &blob);
}
Expand All @@ -186,9 +192,11 @@
}
return globalThis.throwInvalidArguments("Expected a path to check the size of", .{});
};
errdefer path.deinit();
var path_consumed = false;
errdefer if (!path_consumed) path.deinit();
const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
path_consumed = true;
defer blob.detach();
return S3File.S3BlobStatTask.size(globalThis, &blob);
}
Expand All @@ -203,9 +211,11 @@
}
return globalThis.throwInvalidArguments("Expected a path to check the stat of", .{});
};
errdefer path.deinit();
var path_consumed = false;
errdefer if (!path_consumed) path.deinit();
const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
path_consumed = true;
defer blob.detach();
return S3File.S3BlobStatTask.stat(globalThis, &blob);
}
Expand All @@ -217,13 +227,15 @@
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to write to", .{}).throw();
};
errdefer path.deinit();
var path_consumed = false;
errdefer if (!path_consumed) path.deinit();
const data = args.nextEat() orelse {
return globalThis.ERR(.MISSING_ARGS, "Expected a Blob-y thing to write", .{}).throw();
};

const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
path_consumed = true;
defer blob.detach();
var blob_internal: PathOrBlob = .{ .blob = blob };
return Blob.writeFileInternal(globalThis, &blob_internal, data, .{
Expand Down Expand Up @@ -251,9 +263,11 @@
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to unlink", .{}).throw();
};
errdefer path.deinit();
var path_consumed = false;
errdefer if (!path_consumed) path.deinit();
const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ptr.credentials, ptr.options, ptr.acl, ptr.storage_class, ptr.request_payer);
path_consumed = true;
defer blob.detach();
return blob.store.?.data.s3.unlink(blob.store.?, globalThis, options);
}
Expand Down
24 changes: 18 additions & 6 deletions src/runtime/webcore/S3File.zig
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ pub fn presign(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.J

// accept a path or a blob
var path_or_blob = try PathOrBlob.fromJSNoCopy(globalThis, &args);
var path_consumed = false;
errdefer {
if (path_or_blob == .path) {
if (!path_consumed and path_or_blob == .path) {
path_or_blob.path.deinit();
}
}
Expand All @@ -84,6 +85,7 @@ pub fn presign(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.J
}
const options = args.nextEat();
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_consumed = true;
defer blob.deinit();
return try getPresignUrlFrom(&blob, globalThis, options);
},
Expand All @@ -98,8 +100,9 @@ pub fn unlink(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS

// accept a path or a blob
var path_or_blob = try PathOrBlob.fromJSNoCopy(globalThis, &args);
var path_consumed = false;
errdefer {
if (path_or_blob == .path) {
if (!path_consumed and path_or_blob == .path) {
path_or_blob.path.deinit();
}
}
Expand All @@ -114,6 +117,7 @@ pub fn unlink(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
}
const options = args.nextEat();
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_consumed = true;
defer blob.deinit();
return try blob.store.?.data.s3.unlink(blob.store.?, globalThis, options);
},
Expand All @@ -130,8 +134,9 @@ pub fn write(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSE

// accept a path or a blob
var path_or_blob = try PathOrBlob.fromJSNoCopy(globalThis, &args);
var path_consumed = false;
errdefer {
if (path_or_blob == .path) {
if (!path_consumed and path_or_blob == .path) {
path_or_blob.path.deinit();
}
}
Expand All @@ -151,6 +156,7 @@ pub fn write(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSE
return globalThis.throwInvalidArguments("Expected a S3 or path to upload", .{});
}
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_consumed = true;
defer blob.deinit();

var blob_internal: PathOrBlob = .{ .blob = blob };
Expand All @@ -173,8 +179,9 @@ pub fn size(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSEr

// accept a path or a blob
var path_or_blob = try PathOrBlob.fromJSNoCopy(globalThis, &args);
var path_consumed = false;
errdefer {
if (path_or_blob == .path) {
if (!path_consumed and path_or_blob == .path) {
path_or_blob.path.deinit();
}
}
Expand All @@ -190,6 +197,7 @@ pub fn size(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSEr
return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{});
}
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_consumed = true;
defer blob.deinit();

return S3BlobStatTask.size(globalThis, &blob);
Expand All @@ -206,8 +214,9 @@ pub fn exists(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS

// accept a path or a blob
var path_or_blob = try PathOrBlob.fromJSNoCopy(globalThis, &args);
var path_consumed = false;
errdefer {
if (path_or_blob == .path) {
if (!path_consumed and path_or_blob == .path) {
path_or_blob.path.deinit();
}
}
Expand All @@ -223,6 +232,7 @@ pub fn exists(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
return globalThis.throwInvalidArguments("Expected a S3 or path to check if it exists", .{});
}
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_consumed = true;
defer blob.deinit();

return S3BlobStatTask.exists(globalThis, &blob);
Expand Down Expand Up @@ -557,8 +567,9 @@ pub fn stat(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSEr

// accept a path or a blob
var path_or_blob = try PathOrBlob.fromJSNoCopy(globalThis, &args);
var path_consumed = false;
errdefer {
if (path_or_blob == .path) {
if (!path_consumed and path_or_blob == .path) {
path_or_blob.path.deinit();
}
}
Expand All @@ -574,6 +585,7 @@ pub fn stat(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSEr
return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{});
}
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_consumed = true;
defer blob.deinit();

return S3BlobStatTask.stat(globalThis, &blob);
Expand Down
62 changes: 62 additions & 0 deletions test/js/bun/s3/s3-error-path-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";

// When an S3 operation fails after the blob store is created (e.g. missing
// credentials in signRequest), the path string's ownership has already been
// transferred to the store via toThreadSafe(). The caller's errdefer must not
// deinit it again or the underlying StringImpl gets double-freed.
describe("S3 methods do not double-free path on error", () => {
const paths = ["abc", "foo/bar", "xyz" + Math.random().toString(36).slice(2)];

describe.each(paths)("path=%s", p => {
test("S3Client instance presign", () => {
expect(() => Bun.s3.presign(p)).toThrow();
expect(() => Bun.s3.presign(p, new SharedArrayBuffer(16))).toThrow();
expect(() => new Bun.S3Client({}).presign(p)).toThrow();
});

test("S3Client static presign", () => {
expect(() => Bun.S3Client.presign(p)).toThrow();
});

test("S3Client unlink", () => {
const a = Bun.s3.unlink(p);
const b = Bun.S3Client.unlink(p);
expect(a).rejects.toThrow();
expect(b).rejects.toThrow();
});

test("S3Client write", () => {
const a = Bun.s3.write(p, "x");
const b = Bun.S3Client.write(p, "x");
expect(a).rejects.toThrow();
expect(b).rejects.toThrow();
});
});

test("valid presign still works", () => {
const client = new Bun.S3Client({
accessKeyId: "a",
secretAccessKey: "b",
bucket: "c",
endpoint: "http://localhost",
});
expect(client.presign("abc")).toStartWith("http://localhost/c/abc?");
});

test("early failure before store creation still cleans up path", () => {
expect(() => Bun.s3.presign("abc", { accessKeyId: 123 })).toThrow();
expect(() => new Bun.S3Client({}).presign("abc", { accessKeyId: 123 })).toThrow();
});

test("repeated calls under GC", () => {
for (let i = 0; i < 50; i++) {
try {
Bun.s3.presign("k" + i);
} catch {}
try {
Bun.S3Client.presign("k" + i);
} catch {}
}
Bun.gc(true);
});
});
Loading