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
18 changes: 12 additions & 6 deletions src/bun.js/webcore/S3Client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
if (args.len() == 0) {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to presign", .{}).throw();
}
Comment on lines 145 to 151

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 S3Client.file() instance method was missed by this PR's double-deref fix — it still uses const path with errdefer path.deinit() and calls constructS3FileWithS3CredentialsAndOptions(), which can throw after initS3 has already consumed the path's WTFStringImpl reference, triggering the same use-after-free crash. Apply the same fix as the sibling methods: change const path to var path and add path = .{ .string = bun.PathString.empty }; after the successful Blob.new(...) call.

Extended reasoning...

What the bug is and how it manifests

The PR correctly identifies and fixes the double-deref pattern in six instance methods of S3Client (presign, exists, size, stat, write, unlink) and six static functions in S3File.zig. However, the file() instance method at S3Client.zig lines 128–142 was left unfixed. It still declares const path: jsc.Node.PathLike with errdefer path.deinit() in scope, and calls constructS3FileWithS3CredentialsAndOptions() without clearing path after construction — the exact pattern this PR set out to eliminate.

The specific code path that triggers it

constructS3FileWithS3CredentialsAndOptions() does the following in order:

  1. Calls bun.handleOom(Blob.Store.initS3(path, ...)) or initS3WithReferencedCredentials(path, ...). Both functions copy the PathLike struct by value and call toThreadSafe() on the copy, which may call orig.deref() on the original WTFStringImpl (when a new thread-safe impl is created). At this point the caller's path is stale.
  2. Installs errdefer store.deinit().
  3. Calls try opts.getTruthyComptime(globalObject, "type") — can throw a JSError (e.g., if a getter throws).
  4. Calls try file_type.toSlice(globalObject, ...) — also can throw a JSError.

Why existing code doesn't prevent it

bun.handleOom cannot return an error, so step 1 always completes and ownership of the path reference transfers to the store before the try-able operations in steps 3–4 run. The PR fixed all other methods by clearing path after step 1, but missed file(). Because path is declared const, the clearing assignment cannot be written without first changing it to var.

What the impact would be

If step 3 or 4 throws a JSError, the inner errdefer store.deinit() runs (decrementing and freeing the path ref held by the store), then file()'s errdefer path.deinit() runs on the already-freed WTFStringImpl — a use-after-free. In debug builds this trips hasAtLeastOneRef(); in release builds it is a SIGFPE or silent heap corruption, exactly as described in the PR.

How to fix it

Same pattern as every other fixed method:

  1. Change const path: jsc.Node.PathLikevar path: jsc.Node.PathLike at line 132.
  2. Add path = .{ .string = bun.PathString.empty }; immediately after the successful Blob.new(...) call (before return blob.toJS(globalThis)).

Step-by-step proof

  1. User calls new Bun.S3Client({}).file(jsStringVar, { get type() { throw new Error('oops') } }) where jsStringVar is backed by a non-thread-safe JS string (a WTF StringImpl with refcount 1).
  2. file() parses the path → path holds a slice_with_underlying_string PathLike referencing that StringImpl (refcount 2).
  3. constructS3FileWithS3CredentialsAndOptions() is called; getCredentialsWithOptions succeeds.
  4. Blob.Store.initS3(path, ...) copies path, calls toThreadSafe() on the copy → allocates a new thread-safe StringImpl, calls orig.deref()StringImpl refcount drops to 1. The store now holds the new ref. The caller's path points to the now-stale original.
  5. errdefer store.deinit() is armed inside constructS3FileWithS3CredentialsAndOptions.
  6. opts.getTruthyComptime(globalObject, "type") invokes the JS getter → throws → JSError propagates.
  7. errdefer store.deinit() fires → StringImpl refcount → 0 → freed.
  8. Error propagates back to file()errdefer path.deinit() fires → calls deref() on the already-freed StringImpl → use-after-free / crash.

Expand All @@ -155,6 +155,7 @@ pub const S3Client = struct {

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 = .{ .string = bun.PathString.empty };
defer blob.detach();
return S3File.getPresignUrlFrom(&blob, globalThis, options);
}
Expand All @@ -163,7 +164,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
if (args.len() == 0) {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to check if it exists", .{}).throw();
}
Expand All @@ -172,6 +173,7 @@ pub const S3Client = struct {
errdefer 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 = .{ .string = bun.PathString.empty };
defer blob.detach();
return S3File.S3BlobStatTask.exists(globalThis, &blob);
}
Expand All @@ -180,7 +182,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
if (args.len() == 0) {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to check the size of", .{}).throw();
}
Expand All @@ -189,6 +191,7 @@ pub const S3Client = struct {
errdefer 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 = .{ .string = bun.PathString.empty };
defer blob.detach();
return S3File.S3BlobStatTask.size(globalThis, &blob);
}
Expand All @@ -197,7 +200,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
if (args.len() == 0) {
return globalThis.ERR(.MISSING_ARGS, "Expected a path to check the stat of", .{}).throw();
}
Expand All @@ -206,6 +209,7 @@ pub const S3Client = struct {
errdefer 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 = .{ .string = bun.PathString.empty };
defer blob.detach();
return S3File.S3BlobStatTask.stat(globalThis, &blob);
}
Expand All @@ -214,7 +218,7 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(3).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var 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();
Expand All @@ -224,6 +228,7 @@ pub const S3Client = struct {

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 = .{ .string = bun.PathString.empty };
defer blob.detach();
var blob_internal: PathOrBlob = .{ .blob = blob };
return Blob.writeFileInternal(globalThis, &blob_internal, data, .{
Expand All @@ -248,12 +253,13 @@ pub const S3Client = struct {
const arguments = callframe.arguments_old(2).slice();
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args.deinit();
const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse {
var 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();
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 = .{ .string = bun.PathString.empty };
defer blob.detach();
return blob.store.?.data.s3.unlink(blob.store.?, globalThis, options);
}
Expand Down
6 changes: 6 additions & 0 deletions src/bun.js/webcore/S3File.zig
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
defer blob.deinit();
return try getPresignUrlFrom(&blob, globalThis, options);
},
Expand Down Expand Up @@ -114,6 +115,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_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
defer blob.deinit();
return try blob.store.?.data.s3.unlink(blob.store.?, globalThis, options);
},
Expand Down Expand Up @@ -151,6 +153,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_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
defer blob.deinit();

var blob_internal: PathOrBlob = .{ .blob = blob };
Expand Down Expand Up @@ -190,6 +193,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_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
defer blob.deinit();

return S3BlobStatTask.size(globalThis, &blob);
Expand Down Expand Up @@ -223,6 +227,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_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
defer blob.deinit();

return S3BlobStatTask.exists(globalThis, &blob);
Expand Down Expand Up @@ -574,6 +579,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_or_blob = .{ .path = .{ .path = .{ .string = bun.PathString.empty } } };
defer blob.deinit();

return S3BlobStatTask.stat(globalThis, &blob);
Expand Down
37 changes: 37 additions & 0 deletions test/js/bun/s3/s3-presign-missing-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";

// When S3Client.presign() (and friends) throws after the temporary S3
// blob has been constructed, the errdefer that cleans up the path used
// to double-deref the underlying WTFStringImpl (ownership had already
// transferred to the blob's store). That tripped a debug assertion /
// SIGFPE in release builds.
test("S3Client.presign with missing credentials throws instead of crashing", async () => {
const script = `
let caught = 0;
try { Bun.S3Client.presign("foo"); } catch { caught++; }
try { Bun.S3Client.presign("foo", {}); } catch { caught++; }
try { Bun.s3.presign("foo"); } catch { caught++; }
try { new Bun.S3Client({}).presign("foo"); } catch { caught++; }
if (caught !== 4) throw new Error("expected all presign calls to throw, got " + caught);
console.log("ok");
`;

// Make sure no ambient S3 credentials make the presign succeed.
const env: Record<string, string> = { ...bunEnv };
for (const k of Object.keys(env)) {
if (k.startsWith("S3_") || k.startsWith("AWS_")) delete env[k];
}

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout.trim()).toBe("ok");
expect(exitCode).toBe(0);
});
Loading