Skip to content

Fix double-free of path string in S3Client methods on error - #30465

Closed
robobun wants to merge 1 commit into
mainfrom
farm/746adc6e/s3-path-double-free
Closed

Fix double-free of path string in S3Client methods on error#30465
robobun wants to merge 1 commit into
mainfrom
farm/746adc6e/s3-path-double-free

Conversation

@robobun

@robobun robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a crash (double-free of the path string) in S3Client / Bun.s3 methods when they throw after constructing the internal S3 blob.

Reproduction

// with no S3 credentials in the environment
try { Bun.S3Client.presign("some/key.txt"); } catch {}
try { Bun.S3Client.presign("some/key.txt"); } catch {}
Bun.gc(true);

crashes with:

  • panic: reached unreachable code (debug), or
  • ASSERTION FAILED: wasRemoved in AtomStringImpl.cpp

Root cause

The static Bun.S3Client.{presign,exists,size,stat,write,unlink} and the corresponding S3Client instance methods all follow this pattern:

var path = try jsc.Node.PathLike.fromJS(globalThis, &args) orelse ...;
errdefer path.deinit();
...
var blob = try S3File.constructS3FileWithS3CredentialsAndOptions(globalThis, path, ...);
defer blob.detach();
return S3File.getPresignUrlFrom(&blob, globalThis, options); // can throw

constructS3FileWithS3CredentialsAndOptions / constructS3FileInternalStore transfer ownership of path into the blob's store (via Blob.Store.initS3, whose path.toThreadSafe() does not add a reference). If the subsequent operation throws, the defer blob.detach() frees the store — including the path — and then the outer errdefer path.deinit() frees it again.

Fix

After the blob is successfully constructed, overwrite the caller's path / path_or_blob with an empty value so the errdefer becomes a no-op for subsequent errors. This matches the existing pattern in Blob.zig (e.g. path_or_fd.* = .{ .path = .{ .string = bun.PathString.empty } } after transferring ownership to Blob.Store.initS3).

How did you verify your code works?

Added test/js/bun/s3/s3-path-double-free.test.ts which exercises all affected static and instance methods on the error path, forces GC, and asserts a clean exit. The test SIGABRTs on current main and passes with this fix.

When an S3 operation (presign, exists, size, stat, write, unlink) given a
path string throws after constructing the internal blob store (e.g. missing
credentials), ownership of the path has already transferred to the blob.
Both defer blob.deinit() and the outer errdefer path.deinit() would then
fire, over-releasing the underlying StringImpl and crashing.

Neutralize the caller's path variable once the blob takes ownership so the
errdefer becomes a no-op on later errors.
@robobun

robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:55 PM PT - May 10th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 30465

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

bun-30465 --bun

@coderabbitai

coderabbitai Bot commented May 10, 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: b482f775-22e4-4fa7-a5d4-b241763dd7bd

📥 Commits

Reviewing files that changed from the base of the PR and between 03ebdf8 and dc475c8.

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

Walkthrough

This PR fixes a double-free vulnerability in S3 path handling by neutralizing path ownership after blob construction. Both S3File and S3Client methods now invalidate or empty the original path variable after transferring ownership to internal blob structures, preventing errdefer cleanup from attempting to deinitialize already-consumed paths.

Changes

S3 Path Double-Free Prevention

Layer / File(s) Summary
S3File Path Invalidation
src/runtime/webcore/S3File.zig
presign, unlink, write, size, exists, and stat reassign path_or_blob to an invalid fd sentinel after constructing the internal S3 blob, preventing errdefer cleanup from dereferencing the transferred path.
S3Client Path Neutralization
src/runtime/webcore/S3Client.zig
presign, exists, size, stat, write, and unlink reassign path to an empty PathLike after constructing the S3File blob, neutralizing the errdefer path.deinit() cleanup on the already-consumed value.
Regression Test
test/js/bun/s3/s3-path-double-free.test.ts
Spawns a subprocess that exercises S3Client methods with invalid inputs on error paths, forces garbage collection, and verifies no panic or double-free signal occurs at subprocess exit.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main fix: resolving a double-free bug in S3Client methods.
Description check ✅ Passed The description comprehensively covers both required sections: it clearly explains what the PR does and how it was verified with a detailed test.
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): double free of path when S3Client operation throws #29656 - Same S3 path double-free fix: neutralizes caller's errdefer after blob takes path ownership
  2. s3: fix path double-free when presign throws after blob creation #30351 - Same double-free fix: nullifies path to empty PathLike after blob construction in all 6 S3 methods
  3. S3Client: don't double-deref path when a method fails after blob construction #30419 - Same double-free fix: clears path after ownership transfer, also avoids re-reading options.type after initS3
  4. Fix double-free of path in S3 static methods on error paths #28495 - Same double-free in S3File static methods: sets path_or_blob to .fd variant so errdefer is a no-op
  5. fix(s3): don't double-free path when S3Client static ops throw after blob creation #29081 - Same double-free in S3File static methods after constructS3FileInternalStore
  6. Fix double-free in S3 static methods when path is passed as string #28592 - Same double-free fix using a different approach: clones encoded_slice in initS3 so store owns independent copy
  7. Fix use-after-free in S3 Store.initS3 PathLike refcounting #28417 - Same double-free due to PathLike refcount not incremented when initS3 takes ownership via toThreadSafe

🤖 Generated with Claude Code

@robobun

robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29656 (and several others: #28495, #30351, #30419). Closing in favor of the earlier PR.

@robobun robobun closed this May 10, 2026
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-free still occurs if constructS3FileWithS3CredentialsAndOptions / constructS3FileInternalStore throws after Blob.Store.initS3 has taken ownership of the path — e.g. when try opts.getTruthyComptime(globalObject, "type") or try file_type.toSlice(...) throws. In that case the inner errdefer store.deinit() frees the path, the error propagates, and the caller's errdefer path.deinit() fires again because this neutralization line is never reached. The neutralization needs to happen at the actual transfer point — e.g. have the construct functions take path by pointer and clear it immediately after initS3 (the same pattern this PR cites in Blob.zig), which also fixes the unmodified S3Client.file().

Extended reasoning...

What the bug is

This PR neutralizes the caller's path / path_or_blob variable on the line after try constructS3FileWithS3CredentialsAndOptions(...) / try constructS3FileInternalStore(...) returns. That closes the window for errors thrown after the construct call (e.g. getPresignUrlFrom throwing on missing credentials), but it leaves open a window inside the construct functions: ownership of path transfers to the store at Blob.Store.initS3(...), an errdefer store.deinit() is registered, and then there are still fallible calls — try opts.getTruthyComptime(globalObject, "type") and try file_type.toSlice(...). If either throws, the path is freed twice exactly as before.

The code path

In constructS3FileWithS3CredentialsAndOptions (and identically in constructS3FileWithS3Credentials, which constructS3FileInternalStore wraps):

var aws_options = try S3.S3Credentials.getCredentialsWithOptions(...);   // (1) may throw — BEFORE transfer, safe
const store = ... Blob.Store.initS3(path, ...);                          // (2) ownership of `path` moves into store
errdefer store.deinit();                                                 // (3)
...
if (try opts.getTruthyComptime(globalObject, "type")) |file_type| {      // (4) may throw — AFTER transfer
    ...
    var str = try file_type.toSlice(globalObject, bun.default_allocator); // (5) may throw — AFTER transfer

Blob.Store.initS3 calls path.toThreadSafe(), which (per SliceWithUnderlyingString.toThreadSafe) transfers the existing reference rather than adding one — the same fact this PR's description relies on. So after (2), the store holds the only reference to the path's StringImpl.

If (4) or (5) throws:

  • (3) errdefer store.deinit() runs → frees the store, which derefs the path → refcount goes to 0, StringImpl destroyed.
  • The error propagates out of constructS3FileWith* to the caller.
  • In the caller (e.g. S3Client.presign), path = .{ .string = bun.PathString.empty } is the next statement after the try construct... — it never executes.
  • The caller's errdefer path.deinit() (or errdefer path_or_blob.path.deinit() in S3File.zig) runs → derefs the already-freed StringImpl a second time.

That is the same ASSERTION FAILED: wasRemoved / use-after-free crash this PR set out to fix.

Why existing code doesn't prevent it

PathLike.deinit() is a no-op for .string / .buffer but for .slice_with_underlying_string / .threadsafe_string / .encoded_slice it derefs the underlying StringImpl. JS string paths produce one of the latter (otherwise the original bug this PR fixes wouldn't crash), so the second deinit() is not a no-op.

The PR description points to the right pattern — Blob.zig clears path_or_fd.* = .{ .path = .{ .string = bun.PathString.empty } } immediately at the transfer point — but this PR places the clear after the construct function returns instead of at the transfer point inside it.

Step-by-step proof

let n = 0;
const opts = { get type() { if (n++ > 0) throw new Error("boom"); return undefined; } };
new Bun.S3Client({}).presign("some/key.txt", opts);
  1. S3Client.presign parses path (a .slice_with_underlying_string holding one ref to "some/key.txt"'s StringImpl) and registers errdefer path.deinit().
  2. It calls constructS3FileWithS3CredentialsAndOptions(globalThis, path, opts, ...).
  3. Inside, getCredentialsWithOptions reads opts.type once (credentials_jsc.zig:203). The getter returns undefined on the first read — no throw.
  4. Blob.Store.initS3(path, ...) runs: ownership of the path's StringImpl ref moves into store.
  5. errdefer store.deinit() is registered.
  6. try opts.getTruthyComptime(globalObject, "type") reads opts.type again. The getter throws on this second read → error.JSError.
  7. errdefer store.deinit() fires → store is freed, which derefs the path → StringImpl refcount hits 0 and is destroyed.
  8. error.JSError propagates to S3Client.presign. path = .{ .string = bun.PathString.empty } is never reached.
  9. errdefer path.deinit() fires → derefs the destroyed StringImpl again → crash.

(A simple { get type() { throw ... } } would actually throw at step 3, before ownership transfers, which is safe — hence the stateful getter. The window is real regardless; only the repro needs the second-read trick.)

Impact and scope

Memory-safety crash (debug unreachable, release ASSERTION FAILED: wasRemoved in AtomStringImpl.cpp / heap corruption), same as the original bug. The trigger is narrower than the missing-credentials case — it requires options.type to throw on the second read, or file_type.toSlice to throw — but it is reachable from JS and is the exact bug class this PR claims to fix.

It applies to every call site this PR touches (S3Client.{presign,exists,size,stat,write,unlink} and S3File.{presign,exists,size,stat,write,unlink}) and to the unmodified S3Client.file(), where path is still const and never cleared.

How to fix

Move the neutralization to the actual transfer point. The cleanest option is to make constructS3FileWithS3CredentialsAndOptions / constructS3FileWithS3Credentials take path: *jsc.Node.PathLike and do path.* = .{ .string = bun.PathString.empty }; immediately after Blob.Store.initS3(...). Callers then pass &path and can drop the post-call neutralization (and S3Client.file() is fixed for free). Alternatively, neutralize the caller's variable before calling the construct function, since getCredentialsWithOptions doesn't touch path and initS3 is infallible apart from OOM — but the by-pointer approach matches the Blob.zig precedent the PR cites and is robust to future changes.

stdout: "pipe",
stderr: "pipe",
});
expect(stderr.toString()).not.toContain("panic");

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.

🟡 nit: per the root CLAUDE.md testing guidelines, tests should not assert that stderr does not contain "panic" — these checks never fail in CI. The signalCode, stdout == "ok", and exitCode === 0 assertions below already catch the crash, so this line can be dropped.

Extended reasoning...

The repo's root CLAUDE.md (testing section) states:

NEVER write tests that check for no "panic" or "uncaught exception" or similar in the test output. These tests will never fail in CI.

The new test adds exactly this pattern at line 52:

expect(stderr.toString()).not.toContain("panic");

Why it's redundant here. The regression this test guards against is a double-free that manifests as a SIGABRT (debug unreachable / WTF ASSERTION FAILED). The three assertions that follow already detect that failure mode robustly:

  1. expect(signalCode).toBeFalsy() — fails if the child is killed by SIGABRT/SIGSEGV.
  2. expect(stdout.toString().trim()).toBe("ok") — fails if the child dies before reaching console.log("ok").
  3. expect(exitCode).toBe(0) — fails on any non-zero exit.

So the not.toContain("panic") check adds no coverage; if the process panics, one of the above already trips.

Why the guideline exists. In CI, release builds don't necessarily emit the literal string panic on this class of corruption (e.g. the WTF assertion path prints ASSERTION FAILED, and a use-after-free in a fully optimized build may just SIGSEGV with nothing on stderr). A not.toContain("panic") assertion can therefore pass even when the bug is present, giving false confidence — which is precisely why the guideline forbids it.

Step-by-step on current main (without the fix):

  1. Child process double-frees the path string and aborts.
  2. signalCode is "SIGABRT"expect(signalCode).toBeFalsy() fails.
  3. stdout never receives "ok"expect(stdout...).toBe("ok") fails.
  4. The stderr check is never the deciding assertion.

Fix: delete line 52. The remaining three assertions are sufficient and match how other crash-regression tests in this repo are written.

(Noting for context: ~20 existing test files already contain this pattern, so this is a widespread legacy violation — but the guideline is unambiguous for new code, and this PR introduces a fresh instance.)

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