Skip to content

fix(s3): double deref of path string on error in S3Client static methods - #29643

Closed
robobun wants to merge 1 commit into
mainfrom
farm/8b0e2143/fix-s3-static-path-double-deref
Closed

fix(s3): double deref of path string on error in S3Client static methods#29643
robobun wants to merge 1 commit into
mainfrom
farm/8b0e2143/fix-s3-static-path-double-deref

Conversation

@robobun

@robobun robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a crash (reached unreachable code from the hasAtLeastOneRef() assertion in WTFStringImplStruct.deref) when a static Bun.S3Client method that accepts a path — presign, unlink, write, size, exists, stat — throws after successfully constructing the internal S3 blob.

For example, calling Bun.S3Client.presign("some/bucket/key") with no credentials configured would crash instead of throwing ERR_S3_MISSING_CREDENTIALS.

Root cause

var path_or_blob = try PathOrBlob.fromJSNoCopy(globalThis, &args);
errdefer {
    if (path_or_blob == .path) {
        path_or_blob.path.deinit();
    }
}
...
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
defer blob.deinit();
return try getPresignUrlFrom(&blob, globalThis, options);

constructS3FileInternalStore stores the path in the blob's S3 store (transferring ownership). If the subsequent operation throws, defer blob.deinit() derefs the path via the store, then the outer errdefer derefs it again.

Fix

After the blob is created, overwrite path_or_blob with a harmless .fd value so the errdefer's deinit() becomes a no-op once ownership has transferred.

How did you verify your code works?

  • Added a regression test in test/js/bun/s3/s3.test.ts that crashes on main and passes with this fix.
  • Verified the original fuzzer repro no longer crashes.
  • Verified the happy path (S3Client.presign with valid credentials) still produces a URL.

Found by Fuzzilli. Fingerprint: a7f2578a4715b60c

When S3Client.presign/unlink/write/size/exists/stat are called with a
path and the underlying operation throws (e.g. missing credentials),
the path was freed twice: once by blob.deinit() via the blob store,
and again by the errdefer on path_or_blob. Clear path_or_blob after
ownership is transferred to the blob so the errdefer is a no-op.
@robobun

robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:23 AM PT - Apr 23rd, 2026

@robobun, your commit a721934 has 1 failures in Build #47563 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29643

That installs a local version of the PR into your bun-29643 executable, so you can run:

bun-29643 --bun

@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 77a247d7-4163-419d-ba0a-9349f96d8559

📥 Commits

Reviewing files that changed from the base of the PR and between dc578b1 and a721934.

📒 Files selected for processing (2)
  • src/bun.js/webcore/S3File.zig
  • test/js/bun/s3/s3.test.ts

Walkthrough

This PR modifies S3 file operations to properly handle path cleanup in error scenarios by mutating the path_or_blob value to an invalid file descriptor, and adds a test case to validate S3Client.presign error handling when credentials are missing.

Changes

Cohort / File(s) Summary
S3 File Operations
src/bun.js/webcore/S3File.zig
Updated multiple S3 operations (presign, unlink, write, size, exists, stat) to set fd = bun.invalid_fd immediately after blob creation, ensuring proper errdefer cleanup behavior when path conversion occurs.
S3 Tests
test/js/bun/s3/s3.test.ts
Added test validation for S3Client.presign static method to verify correct error handling when S3 credentials are missing, including both key-only and key-with-options call patterns.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: preventing double dereferencing of a path string in S3Client static methods during error scenarios.
Description check ✅ Passed The description comprehensively covers both required sections with clear explanations of the problem, root cause analysis, solution, and verification approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(s3): don't double-free path when S3Client static ops throw after blob creation #29081 - Fixes the same double-free of path in S3Client static methods using the same .fd = bun.invalid_fd sentinel technique
  2. Fix double-free in S3 static methods when path is passed as string #28592 - Fixes the same double-free in S3 static methods by reassigning path_or_blob after ownership transfers
  3. Fix double-free of path in S3 static methods on error paths #28495 - Fixes the same double-free of path in S3 static methods on error paths, with additional fixes in S3Client.zig
  4. Fix use-after-free in S3 Store.initS3 PathLike refcounting #28417 - Fixes the same use-after-free in S3 path handling with a different approach (removing errdefer on path)
  5. Fix crash in S3 presign with missing credentials #28423 - Fixes the same crash in S3 presign with missing credentials (partial overlap, only covers presign)

🤖 Generated with Claude Code

@robobun

robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29081 (same fix, same approach).

@robobun robobun closed this Apr 23, 2026
@robobun
robobun deleted the farm/8b0e2143/fix-s3-static-path-double-deref branch April 23, 2026 18:23
Comment on lines 86 to +87
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } };

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.

🔴 This fix is incomplete: the same double-deref still occurs if constructS3FileWithS3Credentials throws after Blob.Store.initS3 has stored the path (e.g. when the post-initS3 try opts.getTruthyComptime(globalObject, "type") throws). In that case errdefer store.deinit() derefs the path, the error propagates before the new neutralization line runs, and the caller's errdefer path_or_blob.path.deinit() derefs it again. Consider neutralizing path inside constructS3FileWithS3Credentials immediately after initS3 succeeds (or having initS3 ref/dupe the path) so ownership transfer is atomic with respect to errors.

Extended reasoning...

What the bug is

The PR neutralizes path_or_blob after constructS3FileInternalStore returns successfully. But constructS3FileInternalStoreconstructS3FileWithS3Credentials can throw after it has already transferred ownership of the path into the blob store via Blob.Store.initS3. When that happens, the inner errdefer store.deinit() derefs the path, then the caller's outer errdefer path_or_blob.path.deinit() derefs it a second time — the exact hasAtLeastOneRef() crash this PR is meant to fix.

Code path

In constructS3FileWithS3Credentials (S3File.zig):

const store = bun.handleOom(Blob.Store.initS3(path, null, aws_options.credentials, ...));
errdefer store.deinit();                                          // derefs path on error
...
if (try opts.getTruthyComptime(globalObject, "type")) |file_type| {  // can throw
    ...
    var str = try file_type.toSlice(globalObject, ...);           // can throw

initS3 stores the PathLike in the S3 store without adding a net ref (PathLike.toThreadSafe on a slice_with_underlying_string is a no-op when the impl is already thread-safe — this is why the original bug crashed at all). After that point, both the store and the caller's path_or_blob think they own the same +1.

Why the fix doesn't cover it

The neutralization line is placed here:

var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } };   // ← only runs on SUCCESS

If constructS3FileInternalStore throws, control never reaches the neutralization, so the outer errdefer still sees the live path and derefs it again.

Step-by-step proof

Repro (note: type is read once in getCredentialsWithOptions before initS3 and once after, so the getter must succeed on the first read and throw on the second — a plain get type(){ throw 1 } would throw too early):

let n = 0;
Bun.S3Client.presign("some/bucket/key", {
  accessKeyId: "x", secretAccessKey: "x",
  get type() { if (n++) throw new Error("boom"); return "text/plain"; },
});
  1. PathOrBlob.fromJSNoCopy produces a .path with refcount 1; errdefer path_or_blob.path.deinit() is armed.
  2. constructS3FileInternalStoreconstructS3FileWithS3Credentials calls getCredentialsWithOptions, which reads type (1st call → returns "text/plain").
  3. Blob.Store.initS3(path, ...) stores the path in the S3 store (no net ref added). errdefer store.deinit() is armed.
  4. try opts.getTruthyComptime(globalObject, "type") reads type again (2nd call → throws).
  5. errdefer store.deinit() runs → Store.deinitS3.deinitpathlike.deinit()first deref (refcount 1 → 0, string freed).
  6. Error propagates out of constructS3FileInternalStore; the new path_or_blob = .{ .path = .{ .fd = ... } } line is never reached.
  7. Caller's errdefer path_or_blob.path.deinit() runs → second derefhasAtLeastOneRef() assertion crash.

The same applies to all six call sites (presign, unlink, write, size, exists, stat) and also to constructS3FileWithS3CredentialsAndOptions.

Impact

Same crash class as the original Fuzzilli find (reached unreachable code in WTFStringImplStruct.deref). The trigger is more contrived than the original (requires a stateful getter/Proxy that throws on the second access rather than a simple missing-credentials error), but it's squarely in the territory a fuzzer will hit, and it's the exact bug class this PR's title claims to fix.

Suggested fix

Move the ownership transfer to where it actually happens: inside constructS3FileWithS3Credentials (and constructS3FileWithS3CredentialsAndOptions), immediately after initS3 succeeds, either neutralize the by-value path parameter so the inner errdefer is the sole owner, or change the calling convention so callers pass *PathLike and the callee zeroes it on transfer. Alternatively, have initS3 dupe/ref the path so the caller retains independent ownership.

Comment on lines 86 to +87
var blob = try constructS3FileInternalStore(globalThis, path.path, options);
path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } };

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 same double-deref pattern exists in the instance methods in src/bun.js/webcore/S3Client.zig (presign, exists, size, stat, write, unlink, lines ~144-259): errdefer path.deinit() followed by constructS3FileWithS3CredentialsAndOptions (which transfers path ownership into the store) followed by defer blob.detach(). This is pre-existing — the PR doesn't touch that file — but e.g. new Bun.S3Client({accessKeyId:'x',secretAccessKey:'y',bucket:'b'}).presign('key', {expiresIn: -1}) will hit the same hasAtLeastOneRef() crash, so it may be worth applying the same neutralization there (or in a follow-up) so the fuzzer fingerprint doesn't immediately resurface via the instance API.

Extended reasoning...

What the bug is

This PR correctly fixes the double-deref of the path string in the static S3Client methods in src/bun.js/webcore/S3File.zig. However, the instance methods on S3Client in src/bun.js/webcore/S3Client.zig have a structurally identical pattern that is not touched by this PR and will produce the exact same hasAtLeastOneRef() crash.

For example, S3Client.zig:144-159 (presign):

const path: jsc.Node.PathLike = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse { ... };
errdefer path.deinit();

const options = args.nextEat();
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, options, ...);
defer blob.detach();
return S3File.getPresignUrlFrom(&blob, globalThis, options);

The same shape appears in exists (172-176), size (189-193), stat (206-210), write (220-232), and unlink (254-258).

Code path / why nothing prevents it

constructS3FileWithS3CredentialsAndOptions calls Blob.Store.initS3(path, ...) (or initS3WithReferencedCredentials), which stores the PathLike in the blob's S3 store without adding a net ref — ownership transfers, exactly as described in this PR's root-cause analysis. blob.detach() calls store.deref(), which (since the store starts at refcount 1) runs Store.deinit()s3.deinit()pathlike.deinit(). That is the first deref. Because the trailing call returned an error, the errdefer path.deinit() then runs as well — second deref → reached unreachable code in WTFStringImplStruct.deref.

This is the same mechanism the PR fixes for the static methods; the instance methods just live in a different file and use blob.detach() instead of blob.deinit(), but Blob.deinit() simply calls detach(), so the cleanup path is identical.

Step-by-step proof

  1. const client = new Bun.S3Client({ accessKeyId: 'x', secretAccessKey: 'y', bucket: 'b' }) — constructs an S3Client with valid-looking credentials.
  2. client.presign('key', { expiresIn: -1 }) enters S3Client.presign.
  3. PathLike.fromJS succeeds → path holds one ref to the "key" string. errdefer path.deinit() is armed.
  4. constructS3FileWithS3CredentialsAndOptions succeeds (it does not read expiresIn; getCredentialsWithOptions only reads credential/ACL/part-size fields). The returned blob's store now owns path. defer blob.detach() is armed.
  5. getPresignUrlFrom reads expiresIn, sees -1 <= 0, and throws throwInvalidArguments("expiresIn must be greather than 0").
  6. Defers run LIFO: blob.detach()store.deref() → store refcount 1→0 → s3.deinit()pathlike.deinit() (first deref of "key").
  7. errdefer path.deinit() runs → second deref of the same string → hasAtLeastOneRef() assertion fails → crash.

Other easy triggers from the same family: { method: 'OPTIONS' } (invalid method), an oversized sessionToken (sign error ERR_S3_INVALID_SESSION_TOKEN), or for exists/size/stat/unlink any condition that makes S3.stat/unlink throw synchronously (e.g. sign error).

Impact

Same impact as the bug this PR fixes: a user-triggerable hard crash (reached unreachable code) instead of a catchable JS error, reachable from the public new Bun.S3Client(...).presign/exists/size/stat/write/unlink API. Since this PR is fixing a Fuzzilli fingerprint for this exact crash class, the fuzzer will likely rediscover it via the instance-method entry point.

How to fix

Apply the same neutralization used in this PR: after constructS3FileWithS3CredentialsAndOptions succeeds, clear the outer owner so the errdefer becomes a no-op. Since these methods hold a bare PathLike (not a PathOrBlob), the simplest equivalent is to make path a var and set path = .{ .fd = bun.invalid_fd }; immediately after the blob is constructed (or alternatively restructure to drop the errdefer once ownership transfers).

This is pre-existingS3Client.zig is not modified by this PR — so it's fine to land as a follow-up, but flagging here since it's the same crash class the PR title/description claims to address.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant