-
Notifications
You must be signed in to change notification settings - Fork 5k
fix(s3): double deref of path string on error in S3Client static methods #29643
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 |
|---|---|---|
|
|
@@ -83,7 +83,8 @@ | |
| return globalThis.throwInvalidArguments("Expected a S3 or path to presign", .{}); | ||
| } | ||
| const options = args.nextEat(); | ||
| var blob = try constructS3FileInternalStore(globalThis, path.path, options); | ||
| path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } }; | ||
|
Check failure on line 87 in src/bun.js/webcore/S3File.zig
|
||
|
Comment on lines
86
to
+87
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. 🟣 The same double-deref pattern exists in the instance methods in Extended reasoning...What the bug isThis PR correctly fixes the double-deref of the path string in the static For example, 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 Code path / why nothing prevents it
This is the same mechanism the PR fixes for the static methods; the instance methods just live in a different file and use Step-by-step proof
Other easy triggers from the same family: ImpactSame impact as the bug this PR fixes: a user-triggerable hard crash ( How to fixApply the same neutralization used in this PR: after This is pre-existing — |
||
| defer blob.deinit(); | ||
| return try getPresignUrlFrom(&blob, globalThis, options); | ||
| }, | ||
|
|
@@ -114,6 +115,7 @@ | |
| } | ||
| const options = args.nextEat(); | ||
| var blob = try constructS3FileInternalStore(globalThis, path.path, options); | ||
| path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } }; | ||
| defer blob.deinit(); | ||
| return try blob.store.?.data.s3.unlink(blob.store.?, globalThis, options); | ||
| }, | ||
|
|
@@ -151,6 +153,7 @@ | |
| return globalThis.throwInvalidArguments("Expected a S3 or path to upload", .{}); | ||
| } | ||
| var blob = try constructS3FileInternalStore(globalThis, path.path, options); | ||
| path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } }; | ||
| defer blob.deinit(); | ||
|
|
||
| var blob_internal: PathOrBlob = .{ .blob = blob }; | ||
|
|
@@ -190,6 +193,7 @@ | |
| return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{}); | ||
| } | ||
| var blob = try constructS3FileInternalStore(globalThis, path.path, options); | ||
| path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } }; | ||
| defer blob.deinit(); | ||
|
|
||
| return S3BlobStatTask.size(globalThis, &blob); | ||
|
|
@@ -223,6 +227,7 @@ | |
| 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 = .{ .fd = bun.invalid_fd } }; | ||
| defer blob.deinit(); | ||
|
|
||
| return S3BlobStatTask.exists(globalThis, &blob); | ||
|
|
@@ -574,6 +579,7 @@ | |
| return globalThis.throwInvalidArguments("Expected a S3 or path to get size", .{}); | ||
| } | ||
| var blob = try constructS3FileInternalStore(globalThis, path.path, options); | ||
| path_or_blob = .{ .path = .{ .fd = bun.invalid_fd } }; | ||
| defer blob.deinit(); | ||
|
|
||
| return S3BlobStatTask.stat(globalThis, &blob); | ||
|
|
||
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-deref still occurs if
constructS3FileWithS3Credentialsthrows afterBlob.Store.initS3has stored the path (e.g. when the post-initS3try opts.getTruthyComptime(globalObject, "type")throws). In that caseerrdefer store.deinit()derefs the path, the error propagates before the new neutralization line runs, and the caller'serrdefer path_or_blob.path.deinit()derefs it again. Consider neutralizingpathinsideconstructS3FileWithS3Credentialsimmediately afterinitS3succeeds (or havinginitS3ref/dupe the path) so ownership transfer is atomic with respect to errors.Extended reasoning...
What the bug is
The PR neutralizes
path_or_blobafterconstructS3FileInternalStorereturns successfully. ButconstructS3FileInternalStore→constructS3FileWithS3Credentialscan throw after it has already transferred ownership of the path into the blob store viaBlob.Store.initS3. When that happens, the innererrdefer store.deinit()derefs the path, then the caller's outererrdefer path_or_blob.path.deinit()derefs it a second time — the exacthasAtLeastOneRef()crash this PR is meant to fix.Code path
In
constructS3FileWithS3Credentials(S3File.zig):initS3stores thePathLikein the S3 store without adding a net ref (PathLike.toThreadSafeon aslice_with_underlying_stringis 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'spath_or_blobthink they own the same +1.Why the fix doesn't cover it
The neutralization line is placed here:
If
constructS3FileInternalStorethrows, control never reaches the neutralization, so the outererrdeferstill sees the live path and derefs it again.Step-by-step proof
Repro (note:
typeis read once ingetCredentialsWithOptionsbeforeinitS3and once after, so the getter must succeed on the first read and throw on the second — a plainget type(){ throw 1 }would throw too early):PathOrBlob.fromJSNoCopyproduces a.pathwith refcount 1;errdefer path_or_blob.path.deinit()is armed.constructS3FileInternalStore→constructS3FileWithS3CredentialscallsgetCredentialsWithOptions, which readstype(1st call → returns"text/plain").Blob.Store.initS3(path, ...)stores the path in the S3 store (no net ref added).errdefer store.deinit()is armed.try opts.getTruthyComptime(globalObject, "type")readstypeagain (2nd call → throws).errdefer store.deinit()runs →Store.deinit→S3.deinit→pathlike.deinit()→ first deref (refcount 1 → 0, string freed).constructS3FileInternalStore; the newpath_or_blob = .{ .path = .{ .fd = ... } }line is never reached.errdefer path_or_blob.path.deinit()runs → second deref →hasAtLeastOneRef()assertion crash.The same applies to all six call sites (
presign,unlink,write,size,exists,stat) and also toconstructS3FileWithS3CredentialsAndOptions.Impact
Same crash class as the original Fuzzilli find (
reached unreachable codeinWTFStringImplStruct.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(andconstructS3FileWithS3CredentialsAndOptions), immediately afterinitS3succeeds, either neutralize the by-valuepathparameter so the innererrdeferis the sole owner, or change the calling convention so callers pass*PathLikeand the callee zeroes it on transfer. Alternatively, haveinitS3dupe/ref the path so the caller retains independent ownership.