-
Notifications
You must be signed in to change notification settings - Fork 5k
Fix double-free of path string in S3Client methods on error #30465
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe } from "harness"; | ||
|
|
||
| // When an S3 operation given a path string throws after constructing the | ||
| // internal blob (e.g. missing credentials), the path string must not be | ||
| // dereferenced twice. Previously both `defer blob.deinit()` and the outer | ||
| // `errdefer path.deinit()` fired, over-releasing the underlying StringImpl. | ||
| test("S3Client methods do not double-free the path string when they throw", () => { | ||
| const { exitCode, stdout, stderr, signalCode } = Bun.spawnSync({ | ||
| cmd: [ | ||
| bunExe(), | ||
| "-e", | ||
| ` | ||
| process.on("unhandledRejection", () => {}); | ||
| const methods = ["presign", "exists", "size", "stat", "unlink", "delete"]; | ||
|
|
||
| for (const m of methods) { | ||
| for (let i = 0; i < 3; i++) { | ||
| try { Bun.S3Client[m]("some/key/here.txt"); } catch {} | ||
| try { Bun.S3Client[m]("some/key/here.txt", "not an object"); } catch {} | ||
| } | ||
| } | ||
| for (let i = 0; i < 3; i++) { | ||
| try { Bun.S3Client.write("some/key/here.txt", "data", "not an object")?.catch?.(() => {}); } catch {} | ||
| } | ||
|
|
||
| const client = new Bun.S3Client({}); | ||
| for (const m of methods) { | ||
| for (let i = 0; i < 3; i++) { | ||
| try { client[m]("some/key/here.txt"); } catch {} | ||
| try { client[m]("some/key/here.txt", "not an object"); } catch {} | ||
| } | ||
| } | ||
| for (let i = 0; i < 3; i++) { | ||
| try { client.write("some/key/here.txt", "data", "not an object")?.catch?.(() => {}); } catch {} | ||
| } | ||
|
|
||
| Bun.gc(true); | ||
| console.log("ok"); | ||
| `, | ||
| ], | ||
| env: { | ||
| ...bunEnv, | ||
| AWS_ACCESS_KEY_ID: "", | ||
| AWS_SECRET_ACCESS_KEY: "", | ||
| S3_ACCESS_KEY_ID: "", | ||
| S3_SECRET_ACCESS_KEY: "", | ||
| }, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| expect(stderr.toString()).not.toContain("panic"); | ||
|
Check warning on line 52 in test/js/bun/s3/s3-path-double-free.test.ts
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit: per the root Extended reasoning...The repo's root
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
So the Why the guideline exists. In CI, release builds don't necessarily emit the literal string Step-by-step on current main (without the fix):
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.) |
||
| expect(signalCode).toBeFalsy(); | ||
| expect(stdout.toString().trim()).toBe("ok"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
There was a problem hiding this comment.
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/constructS3FileInternalStorethrows afterBlob.Store.initS3has taken ownership of the path — e.g. whentry opts.getTruthyComptime(globalObject, "type")ortry file_type.toSlice(...)throws. In that case the innererrdefer store.deinit()frees the path, the error propagates, and the caller'serrdefer 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 takepathby pointer and clear it immediately afterinitS3(the same pattern this PR cites inBlob.zig), which also fixes the unmodifiedS3Client.file().Extended reasoning...
What the bug is
This PR neutralizes the caller's
path/path_or_blobvariable on the line aftertry constructS3FileWithS3CredentialsAndOptions(...)/try constructS3FileInternalStore(...)returns. That closes the window for errors thrown after the construct call (e.g.getPresignUrlFromthrowing on missing credentials), but it leaves open a window inside the construct functions: ownership ofpathtransfers to the store atBlob.Store.initS3(...), anerrdefer store.deinit()is registered, and then there are still fallible calls —try opts.getTruthyComptime(globalObject, "type")andtry file_type.toSlice(...). If either throws, the path is freed twice exactly as before.The code path
In
constructS3FileWithS3CredentialsAndOptions(and identically inconstructS3FileWithS3Credentials, whichconstructS3FileInternalStorewraps):Blob.Store.initS3callspath.toThreadSafe(), which (perSliceWithUnderlyingString.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'sStringImpl.If (4) or (5) throws:
errdefer store.deinit()runs → frees the store, which derefs the path → refcount goes to 0,StringImpldestroyed.constructS3FileWith*to the caller.S3Client.presign),path = .{ .string = bun.PathString.empty }is the next statement after thetry construct...— it never executes.errdefer path.deinit()(orerrdefer path_or_blob.path.deinit()inS3File.zig) runs → derefs the already-freedStringImpla 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/.bufferbut for.slice_with_underlying_string/.threadsafe_string/.encoded_sliceit derefs the underlyingStringImpl. JS string paths produce one of the latter (otherwise the original bug this PR fixes wouldn't crash), so the seconddeinit()is not a no-op.The PR description points to the right pattern —
Blob.zigclearspath_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
S3Client.presignparsespath(a.slice_with_underlying_stringholding one ref to"some/key.txt"'sStringImpl) and registerserrdefer path.deinit().constructS3FileWithS3CredentialsAndOptions(globalThis, path, opts, ...).getCredentialsWithOptionsreadsopts.typeonce (credentials_jsc.zig:203). The getter returnsundefinedon the first read — no throw.Blob.Store.initS3(path, ...)runs: ownership of the path'sStringImplref moves intostore.errdefer store.deinit()is registered.try opts.getTruthyComptime(globalObject, "type")readsopts.typeagain. The getter throws on this second read →error.JSError.errdefer store.deinit()fires → store is freed, which derefs the path →StringImplrefcount hits 0 and is destroyed.error.JSErrorpropagates toS3Client.presign.path = .{ .string = bun.PathString.empty }is never reached.errdefer path.deinit()fires → derefs the destroyedStringImplagain → 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, releaseASSERTION FAILED: wasRemovedinAtomStringImpl.cpp/ heap corruption), same as the original bug. The trigger is narrower than the missing-credentials case — it requiresoptions.typeto throw on the second read, orfile_type.toSliceto 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}andS3File.{presign,exists,size,stat,write,unlink}) and to the unmodifiedS3Client.file(), wherepathis stillconstand never cleared.How to fix
Move the neutralization to the actual transfer point. The cleanest option is to make
constructS3FileWithS3CredentialsAndOptions/constructS3FileWithS3Credentialstakepath: *jsc.Node.PathLikeand dopath.* = .{ .string = bun.PathString.empty };immediately afterBlob.Store.initS3(...). Callers then pass&pathand can drop the post-call neutralization (andS3Client.file()is fixed for free). Alternatively, neutralize the caller's variable before calling the construct function, sincegetCredentialsWithOptionsdoesn't touchpathandinitS3is infallible apart from OOM — but the by-pointer approach matches theBlob.zigprecedent the PR cites and is robust to future changes.