Skip to content

Fix double-free of path string on S3 error paths#30437

Closed
robobun wants to merge 1 commit into
mainfrom
farm/19f10a41/fix-s3-path-double-free
Closed

Fix double-free of path string on S3 error paths#30437
robobun wants to merge 1 commit into
mainfrom
farm/19f10a41/fix-s3-path-double-free

Conversation

@robobun

@robobun robobun commented May 9, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.s3.presign("abc") (and the static Bun.S3Client.presign) crashed with reached unreachable code in debug builds when credentials are missing:

panic(main thread): reached unreachable code
  string.wtf.WTFStringImplStruct.deref (wtf.zig:92) — bun.assert(self.hasAtLeastOneRef())
  string.string.SliceWithUnderlyingString.deinit
  runtime.node.types.PathLike.deinit
  runtime.webcore.S3Client.S3Client.presign (S3Client.zig:154) — errdefer path.deinit()

Why

Blob.Store.initS3* receives the PathLike by value and calls path.toThreadSafe() on its local copy. For a .slice_with_underlying_string path this replaces the underlying WTFStringImpl* with an isolated copy and releases the refs the caller was holding on the original impl — ownership is transferred to the store.

If a later step fails (e.g. signRequestMissingCredentials in getPresignUrlFrom), the caller's errdefer path.deinit() fires and derefs the original impl again — a double-free. Static strings ("path", "name", "type", …) masked this because their refcount never drops, so only "normal" path strings tripped the assertion.

How

Add a path_consumed flag set immediately after the blob store is created. The errdefer now only deinits the path when the failure happened before ownership was transferred.

Applied to all affected methods on S3Client (instance + static) and the S3File static entrypoints (presign, unlink, write, exists, size, stat).

Test

test/js/bun/s3/s3-error-path-cleanup.test.ts — panics on the unpatched build, passes after. Also covers the early-failure path (invalid option type before store creation) to ensure the path is still freed there, and a GC stress loop.

Found by Fuzzilli (fingerprint 1ebbda306723eeb6).

When an S3Client/S3File method creates a blob via
constructS3FileWithS3CredentialsAndOptions (or the internal store
variant), Blob.Store.initS3* calls PathLike.toThreadSafe() on a copy of
the caller's path. For WTF-backed strings this transfers the caller's
StringImpl refs to a new isolated copy, leaving the caller's PathLike
pointing at an impl whose refs it no longer owns.

If a subsequent step fails (e.g. signRequest -> MissingCredentials in
getPresignUrlFrom), the caller's `errdefer path.deinit()` fires and
derefs the original impl again, tripping the
`hasAtLeastOneRef()` assertion in debug builds and corrupting memory in
release builds. Only non-static StringImpls hit the assert, which is
why `Bun.s3.presign("abc")` crashed while `Bun.s3.presign("path")`
(a static common identifier) did not.

Guard the errdefer with a `path_consumed` flag that is set once the
blob store has taken ownership, so the path is only deinit'd on
failures that happen before the store is created.
@robobun

robobun commented May 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:47 PM PT - May 9th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 30437

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

bun-30437 --bun

@github-actions github-actions Bot added the claude label May 9, 2026
@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack
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: ddd2b22a-b704-449b-a20c-975f71e27f22

📥 Commits

Reviewing files that changed from the base of the PR and between 6d0d86b and c8fa802.

📒 Files selected for processing (3)
  • src/runtime/webcore/S3Client.zig
  • src/runtime/webcore/S3File.zig
  • test/js/bun/s3/s3-error-path-cleanup.test.ts

Walkthrough

This PR fixes potential double-free issues in S3 path cleanup by introducing a path_consumed flag across seven S3Client methods and six S3File host functions. When blob construction succeeds, the flag prevents errdefer from deinitializing paths whose ownership was transferred. A comprehensive test suite validates error-path cleanup and guards against regressions.

Changes

S3 Path Ownership Cleanup

Layer / File(s) Summary
S3Client Wrapper Methods
src/runtime/webcore/S3Client.zig
Methods file, presign, exists, size, stat, write, and unlink replace unconditional errdefer path.deinit() with a conditional pattern guarded by a path_consumed boolean flag set after successful blob construction.
S3File Host Functions
src/runtime/webcore/S3File.zig
Methods presign, unlink, write, size, exists, and stat add path_consumed tracking and conditionally run .path variant deinitialization in errdefer only when the path was not transferred to blob construction.
Error Path and Regression Tests
test/js/bun/s3/s3-error-path-cleanup.test.ts
New test suite with parameterized failure tests for presign, unlink, and write; control test for successful presigning; early-failure test for blob construction errors; and stress test with GC triggering to verify no double-free crashes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the main fix: preventing double-free of path strings in S3 error paths, which directly corresponds to the core change in the changeset.
Description check ✅ Passed The PR description covers both required sections: 'What' explains the bug symptom and root cause in detail, and 'How' describes the solution approach, though the 'Test' details are not in a separate explicitly titled section.
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

github-actions Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. S3Client: don't double-deref path when a method fails after blob construction #30419 - Same double-free fix: nullifies path after constructS3FileWithS3CredentialsAndOptions consumes it in S3Client.zig and S3File.zig
  2. s3: fix path double-free when presign throws after blob creation #30351 - Same double-free fix in S3Client.zig and S3File.zig, also touches Blob.zig
  3. fix(s3): double free of path when S3Client operation throws #29656 - Nullifies path after blob construction in both S3Client.zig and S3File.zig
  4. fix(s3): don't double-free path when S3Client static ops throw after blob creation #29081 - Invalidates path_or_blob.path after constructS3FileInternalStore in S3File.zig
  5. Fix double-free in S3 static methods when path is passed as string #28592 - Same double-free fix scoped to S3File.zig static methods
  6. Fix double-free of path in S3 static methods on error paths #28495 - Fixes both S3Client.zig and S3File.zig with the same errdefer pattern
  7. Fix crash in S3 presign with missing credentials #28423 - Removes errdefer path.deinit() and restructures presign to avoid the double-free
  8. Fix use-after-free in S3 Store.initS3 PathLike refcounting #28417 - Same root cause, changes path ownership semantics in S3Client.zig and S3File.zig

🤖 Generated with Claude Code

@robobun

robobun commented May 9, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30419 (and #30351, #29656, #29081, #28592, #28495, #28423, #28417 — all open for the same double-free). Closing in favor of the existing PRs.

@robobun robobun closed this May 9, 2026
Comment on lines +156 to +161
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;

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.

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