Restore strong captures in internal stream read callbacks to prevent UAF#6202
Restore strong captures in internal stream read callbacks to prevent UAF#6202
Conversation
|
We can't land this just yet. There's a conflicting change on the internal repo that needs to clear through first. |
There was a problem hiding this comment.
I'm Bonk, and I've done a quick review of your PR.
This PR adds ref = addRef() captures to four lambdas in ReadableStreamInternalController::read() and ::drainingRead() to prevent the owning ReadableStream from being GC'd while async I/O callbacks are pending.
Issues
-
[HIGH] Build failure:
-Wunused-lambda-capture— workerd enables-Wunused-lambda-capture(.bazelrc:unix) and-Werror(CI). Therefvariable is captured but never used in the lambda body, so all four lambdas will produce compiler errors. The established pattern instandard.c++usesJSG_VISITABLE_LAMBDAwhich marks captures as GC-visitable (avoiding the warning). Using plain lambdas requires suppressing the warning. -
[MEDIUM]
jsg::RefinsideaddFunctoris not GC-traceable —addFunctorwraps the lambda inIoOwn<kj::Own<Lambda>>, hiding thejsg::Reffrom V8's GC tracer. Whilejsg::Refuses CppGC reference counting (so it still prevents collection), this diverges from the pattern instandard.c++which usesJSG_VISITABLE_LAMBDAto make refs GC-visible. Consider usingJSG_VISITABLE_LAMBDAinsideaddFunctorfor consistency with the rest of the streams codebase. -
[LOW] No tests were run — the PR description notes that no build or tests were executed. This change is in a critical code path (stream reads). CI should validate this.
This review was generated by an AI assistant and may contain inaccuracies.
| ioContext.addFunctor([this, ref = addRef(), store = js.v8Ref(store), byteOffset, byteLength, | ||
| isByob = maybeByobOptions != kj::none](jsg::Lock& js, | ||
| size_t amount) mutable -> jsg::Promise<ReadResult> { |
There was a problem hiding this comment.
[HIGH] Build will fail: workerd enables -Wunused-lambda-capture in .bazelrc and -Werror in CI. The ref variable is captured but never read in the lambda body, producing a compilation error.
The established pattern in standard.c++ for keeping the owning stream alive uses JSG_VISITABLE_LAMBDA, which lists ref in its visitable-fields tuple (making it "used" by the generated visitForGc method and also properly GC-traceable). For example, from standard.c++:4131:
auto onSuccess = JSG_VISITABLE_LAMBDA((this, ref = addRef(), preventCancel, pipeThrough), (ref), ...);You should either:
- Preferred: Switch to
JSG_VISITABLE_LAMBDAinsideaddFunctorto match the standard controller pattern. - Minimal: Add
(void)ref;at the top of each lambda body to suppress the warning. - Alternative: Restructure to use
.attach(addRef())on the promise (similar toWritableStreamInternalController::ensureWriting()at line 1515).
| [this, ref = addRef()](jsg::Lock& js, | ||
| jsg::Value reason) -> jsg::Promise<ReadResult> { |
There was a problem hiding this comment.
[HIGH] Same -Wunused-lambda-capture issue here — ref is captured but never used. Will fail CI.
Minimal fix if not switching to JSG_VISITABLE_LAMBDA:
| [this, ref = addRef()](jsg::Lock& js, | |
| jsg::Value reason) -> jsg::Promise<ReadResult> { | |
| [this, ref = addRef()](jsg::Lock& js, | |
| jsg::Value reason) -> jsg::Promise<ReadResult> { | |
| (void)ref; |
| ioContext.addFunctor([this, ref = addRef(), store = kj::mv(store)](jsg::Lock& js, | ||
| size_t amount) mutable -> jsg::Promise<DrainingReadResult> { |
There was a problem hiding this comment.
[HIGH] Same -Wunused-lambda-capture issue — ref captured but unused.
| [this, ref = addRef()](jsg::Lock& js, | ||
| jsg::Value reason) -> jsg::Promise<DrainingReadResult> { |
There was a problem hiding this comment.
[HIGH] Same -Wunused-lambda-capture issue — ref captured but unused.
|
I'm Bonk, and I've done a quick review of your PR. The review has been posted to PR #6202 with 4 inline comments. Here's a summary: 1. [HIGH] Build failure: 2. [MEDIUM] 3. [LOW] No tests run — The PR description notes Bazel was blocked by network restrictions. CI needs to validate this change. |
Motivation
ReadableStreamInternalController::read()and::drainingRead()captured rawthisacrossawaitIoLegacy(...).then(...), allowing a pending read promise to run a callback on a destroyed controller and cause a use-after-free.Description
ref = addRef()to the fulfillment and rejection lambdas passed toawaitIoLegacy(...).then(...)inReadableStreamInternalController::read()so the controller is kept alive until the callback runs.ref = addRef()capture to the fulfillment and rejection lambdas inReadableStreamInternalController::drainingRead()to mirror the fix for draining reads.src/workerd/api/streams/internal.c++and the change is limited to the lambda capture lists; no behavioral logic was altered.Testing
bazel query/bazel test) but the environment blocked Bazel bootstrap download (HTTPForbidden), so no build or tests could be executed here.just testorbazel test //src/workerd/...and CI checks to validate the change in a network-enabled environment.Codex Task