Keep escaped handles alive when their escapable scope closes - #223
Keep escaped handles alive when their escapable scope closes#223bkaradzic-microsoft wants to merge 7 commits into
Conversation
napi_escape_handle inserts the escaped handle at the scope start index so that it lives in the parent scope, but napi_close_escapable_handle_scope freed and resized the handle stack back to that same index. The escaped handle was therefore destroyed by the very close it was supposed to survive, so every consumer of napi_escape_handle got a dangling napi_value back. This is reachable from ordinary code: Napi::ObjectReference::Get uses an EscapableHandleScope, and Napi::Error::Message and Napi::Error::what are built on it. Reporting the message of a native error was therefore a heap-use-after-free, which is how this was found. Track which scopes have had a handle escaped and keep that one entry when the scope closes. Escaping now also reports napi_escape_called_twice on a second call, which Node-API requires and which the previous implementation silently allowed. The escape path no longer special cases scope_start == 0: inserting at begin() + 0 is already the correct behavior for that case. Adds NodeApi.EscapedHandleOutlivesItsScope, which reads the escaped value back after closing the scope and churning the parent scope. Under ASan it fails with heap-use-after-free before this change and passes after.
There was a problem hiding this comment.
Pull request overview
Fixes a QuickJS Node-API handle-scope lifetime bug where napi_close_escapable_handle_scope could free an escaped handle, producing dangling napi_values and enabling a heap-use-after-free in common node-addon-api paths (e.g., Napi::Error::what()).
Changes:
- Track escapable scopes that have performed an escape and preserve the escaped handle when closing the escapable scope (QuickJS backend).
- Enforce the Node-API rule of at most one
napi_escape_handlecall per escapable scope (returnnapi_escape_called_twice). - Add a regression unit test ensuring escaped handles outlive the escapable scope and that a second escape is rejected.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| Tests/UnitTests/Shared/Shared.cpp | Adds regression coverage for escaped-handle lifetime and double-escape rejection. |
| Core/Node-API/Source/js_native_api_quickjs.h | Adds env tracking for escapable scopes that have escaped (escaped_scope_starts). |
| Core/Node-API/Source/js_native_api_quickjs.cc | Preserves escaped handle on scope close; rejects double escape; simplifies insertion logic. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The JavaScriptCore shim does not implement the napi_escape_called_twice check, so asserting it in the cross-engine test fails there. That gap is a separate issue from the use-after-free this change fixes, so the test now only covers the portable contract: an escaped handle must still be readable after its scope closes.
Match the convention the other Dispatch-based tests in this file use. Reporting through the promise and returning early also means a failure in the setup calls can no longer leave the promise unset and hang the waiter.
|
Good catch, thanks. Reworked the test to report through the promise and return early, matching the convention the other Dispatch-based tests here use. As you note it also removes a real hazard: an assertion failure in the setup calls would previously have left the promise unset and hung the waiter rather than failing the test. Also dropped the double-escape assertion in a separate commit. The JavaScriptCore shim does not implement the |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Core/Node-API/Source/js_native_api_quickjs.cc:1966
- The newly added
napi_escape_called_twicebranch is not exercised by the regression test, which callsnapi_escape_handleonly once. Add a QuickJS-specific assertion that a second call for the same still-open scope returnsnapi_escape_called_twiceand does not disturb the first escaped handle.
// Node-API allows napi_escape_handle to be called at most once per scope.
if (!env->escaped_scope_starts.insert(scope_start).second) {
return napi_set_last_error(env, napi_escape_called_twice);
| env->handle_scope_stack.insert( | ||
| env->handle_scope_stack.begin() + scope_start, | ||
| std::move(parentPtr) | ||
| ); |
napi_escape_handle inserted the escaped handle into handle_scope_stack at scope_start so it would belong to the parent scope. That insert shifts every entry above it, and scope tokens are built from the stack size at open, so any nested scope still open at that point ends up with a stale recorded start. Closing it then keeps the wrong slot and frees the escaped handle, which reproduces the dangling napi_value this change set set out to fix. Hold the escaped handle in a map on the env instead, keyed by scope start, and push it onto the stack in napi_close_escapable_handle_scope once the scope's own handles are gone. It lands at scope_start, in the parent scope, so it still outlives the close, but the stack is never modified in the middle and no index is ever invalidated. Env teardown frees any handles still held aside for scopes that were never closed. current_scope_start was being adjusted to compensate for the shift; it is only ever written, never read, so that bookkeeping goes away with the insert. Add NestedEscapableScopesBothEscape, which escapes from both an inner and an outer scope and reads both back, and SecondEscapeIsRejected, which covers the napi_escape_called_twice contract. The nested test fails against the previous implementation on every run and passes with this one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
|
The nested-scope report was correct, and it was a real bug rather than a theoretical one. Thanks — I've reworked the fix. What was wrong. How it's fixed. Rather than patching up the indices, escaped handles are now held aside in a map on the env keyed by scope start, and pushed onto the stack in
Tests. Both suggestions are in:
QuickJS and V8 are both 9/9 locally. One pre-existing limitation I'll note but haven't changed here: two scopes opened at the same stack size get identical tokens and are indistinguishable. Fixing that needs stable scope IDs instead of index-derived tokens, which is a bigger change than this PR should carry. |
|
Some real-world evidence for this fix, which I ran into by accident. BabylonNative #1835 has been failing
inline MaybeOrValue<Napi::Value> ObjectReference::Get(const char* utf8name) const {
EscapableHandleScope scope(_env);
...
return scope.Escape(result);
}So any native code that throws a That makes this reachable from ordinary A/B on the same tree, CI flags (clang, QuickJS, RelWithDebInfo, no sanitizers), only this dependency swapped:
Worth noting the crash is intermittent in the way use-after-free usually is (139/1/139), so it reads as flaky CI rather than as a clear bug. |
The Chakra and JavaScriptCore shims implement napi_escape_handle as a pass through that returns the escapee and does not track scopes, so they always report napi_ok and cannot return napi_escape_called_twice. Asserting that contract there fails on three CI jobs for a limitation unrelated to this change, so gate the test on a capability define set from CMake, following the existing JSRUNTIMEHOST_NAPI_ENGINE_JSI pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
The Android tests compile Shared.cpp into their own UnitTestsJNI target, so a define set on the UnitTests target alone left Android_JSC still building and failing the double-escape test. Set it as an INTERFACE definition on napi instead, which reaches every consumer through JsRuntime and AppRuntime, so no test target has to repeat the engine check or be kept in sync. Verified on both sides: JavaScriptCore builds 8 NodeApi tests with the double-escape test excluded, QuickJS builds 9 with it included. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Hermes validates that napi_escape_handle is called on the innermost open scope and returns napi_handle_scope_mismatch otherwise, which is a legitimate refusal rather than a failure. Treat it as such and keep asserting the inner handle, which is the part the regression is about.
924f215 to
6238b5a
Compare
napi_escape_handleinserted the escaped handle at the scope start index so it would live in the parent scope, butnapi_close_escapable_handle_scoperecomputedscope_startfrom the token and calledresize(scope_start), freeing the very handle the close was supposed to preserve. Every caller ofnapi_escape_handlegot a danglingnapi_valueback.This is reachable from ordinary code, not just direct N-API use.
Napi::ObjectReference::Getuses anEscapableHandleScope, andNapi::Error::Message()/Napi::Error::what()are built on it, so reading the message of a native error on QuickJS was a heap-use-after-free.How this was found
BabylonNative #1835 adds tests that make a native module throw.
ExternalCallback::Callbackcallse.what()when there is no pending QuickJS exception, which walks straight into the freed handle. ItsUbuntu_Clang_QuickJSjob segfaulted while every other engine and platform passed.The change
Escaped handles are held aside in a
std::map<size_t, std::unique_ptr<JSValue>>on the env, keyed by scope start, and pushed onto the handle stack bynapi_close_escapable_handle_scopeonce the scope's own handles are gone. They land atscope_start, in the parent scope, so they outlive the close.The handle stack is never modified in the middle. That matters: scope tokens are derived from the stack size at open, so the original insert-at-
scope_startshifted every entry above it and invalidated the recorded start of any nested scope still open — closing that scope would then keep the wrong slot and free the escaped handle, reintroducing the same dangling value. Holding handles aside removes that whole class of bug rather than compensating for it.napi_escape_handlealso now returnsnapi_escape_called_twiceon a second call for the same scope, as Node-API requires.current_scope_startbookkeeping went away with the insert; it was written in several places and never read.Env teardown frees handles still held aside for scopes that were never closed, which the previous stack-based version got for free.
Testing
Three tests, all in
Tests/UnitTests/Shared/Shared.cpp:EscapedHandleOutlivesItsScope— reads the escaped value back after closing the scope and churning the parent. Reproduces the originalheap-use-after-freeunder ASan without the fix.NestedEscapableScopesBothEscape— escapes from an inner and an outer scope and reads both back. Verified to fail on every run against the previous implementation.SecondEscapeIsRejected— covers thenapi_escape_called_twicecontract. Compiled out on Chakra and JavaScriptCore, whose shims implementnapi_escape_handleas a pass through that cannot report it; the capability is published as an INTERFACE define on thenapitarget so every consumer, including the separate Android test target, picks it up.Locally: QuickJS 9/9, V8 9/9, JavaScriptCore 8/8.
End to end, on BabylonNative #1835 with clang + QuickJS + RelWithDebInfo, changing only this dependency: