From 4d94f7c92e3c32e8f52919a376d0d82704e783a2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 28 Sep 2026 07:57:37 +0800 Subject: [PATCH] v8: always detach global Simplifies code and causes v8 to null the microtask queue, removing the possibility of a UAF --- src/browser/Session.zig | 2 - src/browser/js/Context.zig | 26 ++++------ .../detached_realm_promise_handler.html | 48 +++++++++++++++++++ src/server/cdp/CDP.zig | 4 -- 4 files changed, 56 insertions(+), 24 deletions(-) create mode 100644 src/browser/tests/frames/detached_realm_promise_handler.html diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 64c4b43a4..1f23a8f7e 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -743,7 +743,6 @@ fn _processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) const frame_id = frame._frame_id; const reuse_window = frame.window; const page = frame.page; - frame.js.detachGlobal(); frame.deinit(); frame.* = undefined; @@ -794,7 +793,6 @@ fn processPopupNavigation(_: *Session, frame: *Frame, qn: *QueuedNavigation) !vo const frame_id = frame._frame_id; const page = frame.page; - frame.js.detachGlobal(); frame.deinit(); frame.* = undefined; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 2003b876c..9b0cd028a 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -216,6 +216,14 @@ pub fn deinit(self: *Context) void { // have a dangling pointer to our freed Context struct. v8.v8__Context__SetAlignedPointerInEmbedderData(entered.handle, 1, null); + // Detach the global so that a navigation can attach the reused Window to + // the frame's next context. This also nulls v8's pointer to our + // microtask_queue, which we free below. The v8 context can outlive us when + // another realm holds one of our functions, e.g. as a promise handler, and + // resolving that promise would enqueue onto the freed queue. With the + // pointer null, v8 drops the job instead. + v8.v8__Context__DetachGlobal(entered.handle); + v8.v8__Global__Reset(&self.handle); env.isolate.notifyContextDisposed(); // There can be other tasks associated with this context that we need to @@ -224,24 +232,6 @@ pub fn deinit(self: *Context) void { v8.v8__MicrotaskQueue__DELETE(self.microtask_queue); } -// The global (e.g. Window) can be reused across contexts. If you do: -// -// var w = iframe.contentWindow; -// iframe.src = 'two.html'; -// w === iframe.contentWindow (must be true) -// -// so when we navigate, the Window/Global is re-used. That's fine with v8, but -// we need to explicitly detach it from the original before we can safely attach -// it to the new -pub fn detachGlobal(self: *Context) void { - var hs: js.HandleScope = undefined; - hs.init(self.isolate); - defer hs.deinit(); - - const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle)); - v8.v8__Context__DetachGlobal(local_v8_context); -} - // setOrigin is called at navigation (opaque -> real origin) and again when a // script sets document.domain (real origin -> '!'-marked effective domain). pub fn setOrigin(self: *Context, key: ?[]const u8) !void { diff --git a/src/browser/tests/frames/detached_realm_promise_handler.html b/src/browser/tests/frames/detached_realm_promise_handler.html new file mode 100644 index 000000000..005139021 --- /dev/null +++ b/src/browser/tests/frames/detached_realm_promise_handler.html @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index cf1dd2e84..c84f7bf75 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -1371,10 +1371,6 @@ pub const IsolatedWorld = struct { } fn destroyFrameContext(self: *IsolatedWorld, fc: FrameContext) void { - // A re-navigating child frame keeps its Window, and the identity map - // keeps the window's global proxy; detach it from this context so the - // frame's next context can reattach it (as the main world does). - fc.context.detachGlobal(); self.browser.env.destroyContext(fc.context); fc.call_arena.release(); fc.local_arena.release();