Merge pull request #2742 from lightpanda-io/window_reuse

uaf, webapi: Window reuse
This commit is contained in:
Karl Seguin
2026-06-18 11:34:39 +08:00
committed by GitHub
11 changed files with 111 additions and 19 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.4.7'
default: 'v0.4.8'
v8:
description: 'v8 version to install'
required: false

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig-v8 release tag the prebuilt lib came from'
required: false
default: 'v0.4.7'
default: 'v0.4.8'
runs:
using: "composite"

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.4.7
ARG ZIG_V8=v0.4.8
ARG TARGETPLATFORM
RUN apt-get update -yq && \

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.7.tar.gz",
.hash = "v8-0.0.0-xddH63edBADvfTFAnAlB6WutVta1yhsLuyPdXXt_BnKK",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.8.tar.gz",
.hash = "v8-0.0.0-xddH65qdBAA1hmB-gqEmhEzO0-aXKyEsmo1s34mssemb",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -288,11 +288,23 @@ pub const HttpHeader = struct {
value: []const u8,
};
pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void {
pub const InitOpts = struct {
parent: ?*Frame = null,
// When a frame/popup re-navigates, we should preserve the same window.
// There are a couple reasons for this. First, iframe.contentWindow should
// maintain the same identity. Secondly, a reference to the window can be
// acquired prior to navigation, and then used after. So it should remain valid.
reuse_window: ?*Window = null,
};
pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void {
if (comptime IS_DEBUG) {
log.debug(.frame, "frame.init", .{});
}
const parent = opts.parent;
const session = page.session;
const call_arena = try session.getArena(.medium, "call_arena");
errdefer session.releaseArena(call_arena);
@@ -341,7 +353,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void {
});
}
self.window = try factory.eventTarget(Window{
const window_template = Window{
._frame = self,
._proto = undefined,
._document = self.document,
@@ -350,7 +362,16 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void {
._screen = screen,
._visual_viewport = visual_viewport,
._cross_origin_wrapper = undefined,
});
};
if (opts.reuse_window) |w| {
const proto = w._proto;
w.* = window_template;
w._proto = proto;
self.window = w;
} else {
self.window = try factory.eventTarget(window_template);
}
self.window._cross_origin_wrapper = .{ .window = self.window };
self._style_manager = try StyleManager.init(self);
@@ -1463,7 +1484,7 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
const new_frame = try self.arena.create(Frame);
const frame_id = session.nextFrameId();
try Frame.init(new_frame, frame_id, self._page, self);
try Frame.init(new_frame, frame_id, self._page, .{ .parent = self });
errdefer new_frame.deinit();
self._pending_loads += 1;
@@ -1587,7 +1608,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
errdefer page.frame_arena.destroy(popup);
const frame_id = session.nextFrameId();
try Frame.init(popup, frame_id, page, null);
try Frame.init(popup, frame_id, page, .{});
errdefer popup.deinit();
popup.window._opener = opts.opener;

View File

@@ -126,7 +126,7 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
};
self.queued_navigation = &self.queued_navigation_1;
try Frame.init(&self.frame, frame_id, self, null);
try Frame.init(&self.frame, frame_id, self, .{});
}
// Tear down the Page and its root Frame. Equivalent to the old

View File

@@ -530,6 +530,7 @@ fn _processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
}
const frame_id = frame._frame_id;
const reuse_window = frame.window;
const page = self.currentPage().?;
frame.deinit();
frame.* = undefined;
@@ -546,7 +547,7 @@ fn _processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
}
}
try Frame.init(frame, frame_id, page, parent);
try Frame.init(frame, frame_id, page, .{ .parent = parent, .reuse_window = reuse_window });
errdefer {
if (parent_notified) {
parent._pending_loads -= 1;
@@ -563,14 +564,16 @@ fn _processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
};
}
// Re-navigates a popup Frame in place. The Frame pointer stays stable
// (scripts in the opener may hold a cached Window ref — though the Window
// object inside is replaced, matching how iframes behave on navigation).
// Re-navigates a popup Frame in place. Both the Frame pointer and its Window
// stay stable across the re-init, so a cached `window.open()` return value keeps
// a valid `.location` instead of dangling against the freed one.
fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void {
// Preserve popup identity fields. _name lives in the Page arena and
// survives Frame.deinit; _opener is just a pointer.
const saved_name = frame.window._name;
const saved_opener = frame.window._opener;
const reuse_window = frame.window;
const saved_name = reuse_window._name;
const saved_opener = reuse_window._opener;
const frame_id = frame._frame_id;
const page = self.currentPage().?;
@@ -587,7 +590,7 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
}
}
try Frame.init(frame, frame_id, page, null);
try Frame.init(frame, frame_id, page, .{ .reuse_window = reuse_window });
errdefer frame.deinit();
frame.window._name = saved_name;

View File

@@ -254,11 +254,20 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?;
errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue);
// Reuse the window's existing global proxy across an in-place navigation.
// The same *Window keeps its identity_map entry so reattaching it to the
// new context preserves WindowProxy identity
const reuse_global_object: ?*const v8.Value = blk: {
if (comptime !is_frame) break :blk null;
const existing = params.identity.identity_map.getPtr(@intFromPtr(global.window)) orelse break :blk null;
break :blk @ptrCast(v8.v8__Global__Get(existing, isolate.handle));
};
// Restore the context from the snapshot (0 = Page, 1 = Worker)
const snapshot_index: u32 = if (comptime is_frame) 0 else 1;
const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{
.global_template = null,
.global_object = null,
.global_object = reuse_global_object,
.microtask_queue = microtask_queue,
}).?;

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
<title>win_a</title>

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
<title>win_b</title>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<head></head>
<body>
<script src="../testing.js"></script>
<!--
Navigating a browsing context in place (iframe.src change / popup re-nav) reuses
the same Window object, so a cached `iframe.contentWindow` / `window.open()`
return value keeps a VALID `.location` across the navigation. Previously the
in-place Frame re-init built a brand-new Window and freed the old one's
Location, so the cached reference dangled — reading `.location` re-wrapped the
freed Location into a double-free (URL.zig release-overflow).
Note: strict WindowProxy identity (`w === iframe.contentWindow` across the
navigation) is NOT yet provided — a Window is a v8 context's global object and
the context is recreated per navigation. Achieving that needs v8 global-proxy
reuse and is tracked separately. This test guards the UAF, not the identity.
-->
<script id=iframe_contentwindow_location_survives_navigation type=module>
const state = await testing.async();
const iframe = document.createElement('iframe');
const onceLoad = () => new Promise((r) => { iframe.onload = () => r(); });
let loaded = onceLoad();
iframe.src = 'support/win_a.html';
document.body.appendChild(iframe);
await loaded;
// Cache the window WITHOUT reading .location first, so its initial Location
// never gets a JS wrapper pinning it (the original crash condition).
const w = iframe.contentWindow;
loaded = onceLoad();
iframe.src = 'support/win_b.html'; // in-place re-init; Window is reused
await loaded;
state.resolve();
await state.done(() => {
// Same Window object across the navigation: the v8 global proxy is reused
// (WindowProxy identity).
testing.expectEqual(true, w === iframe.contentWindow);
// And reading the cached window's location returns the new, valid location
// (previously a UAF double-free on the freed Location).
testing.expectEqual(true, w.location.href.endsWith('/support/win_b.html'));
});
</script>
<!--
The popup re-navigation path (Session.processPopupNavigation) reuses the Window
the same way; it's exercised by the manual repro at wpt/_lprepro/opener.html
(real HTTP popup load + postMessage aren't driven by this htmlRunner harness).
-->
</body>