From 2b5456ca0b26786d9fb12dee6550840bb1bd0710 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 17 Jun 2026 22:00:51 +0200 Subject: [PATCH] webapi: structured-clone window/MessagePort postMessage messages window.postMessage and MessagePort.postMessage stashed the message js.Value.Temp verbatim and handed it straight into the MessageEvent, with no structuredClone on the path. The receiver got the literal object the sender posted, so a mutation on either side was visible to the other; worse, posting to a different same-origin frame exposed the source realm's object directly into the target realm instead of minting a fresh one there. The spec requires StructuredSerialize in postMessage and deserialize in the destination realm. --- src/browser/tests/message_channel.html | 37 +++++++++++ src/browser/tests/window/post_message.html | 41 +++++++++++++ src/browser/webapi/MessagePort.zig | 71 +++++++++++++++++----- src/browser/webapi/Window.zig | 62 ++++++++++++++----- 4 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 src/browser/tests/window/post_message.html diff --git a/src/browser/tests/message_channel.html b/src/browser/tests/message_channel.html index 65df1c986..4ca665da0 100644 --- a/src/browser/tests/message_channel.html +++ b/src/browser/tests/message_channel.html @@ -66,6 +66,43 @@ testing.expectEqual(42, received4.value); }); + + + + + diff --git a/src/browser/tests/window/post_message.html b/src/browser/tests/window/post_message.html new file mode 100644 index 000000000..73acddd7e --- /dev/null +++ b/src/browser/tests/window/post_message.html @@ -0,0 +1,41 @@ + + + + + + diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig index 1fa57a1b9..a2af00fb1 100644 --- a/src/browser/webapi/MessagePort.zig +++ b/src/browser/webapi/MessagePort.zig @@ -50,7 +50,7 @@ pub fn entangle(port1: *MessagePort, port2: *MessagePort) void { port2._entangled_port = port1; } -pub fn postMessage(self: *MessagePort, message: js.Value.Temp, frame: *Frame) !void { +pub fn postMessage(self: *MessagePort, message: js.Value, frame: *Frame) !void { if (self._closed) { return; } @@ -60,16 +60,39 @@ pub fn postMessage(self: *MessagePort, message: js.Value.Temp, frame: *Frame) !v return; } + // StructuredSerialize runs synchronously (per spec): clone the message into a + // fresh, self-owned temp now so the receiver gets an independent copy (a + // mutation on one side isn't visible to the other) and an unserializable + // value throws a DataCloneError to the caller. Mirrors Worker.postMessage. + const cloned = blk: { + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + // Contain any V8 exception from a failed serialization so it surfaces as + // a clean DataCloneError; deinit() (no rethrow) clears it. + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const c = message.structuredCloneTo(&ls.local) catch { + return error.DataClone; + }; + break :blk try c.temp(); + }; + errdefer cloned.release(); + // Create callback to deliver message const callback = try frame._factory.create(PostMessageCallback{ .frame = frame, .port = other, - .message = message, + .message = cloned, }); try frame.js.scheduler.add(callback, PostMessageCallback.run, 0, .{ .name = "MessagePort.postMessage", .low_priority = false, + .finalizer = PostMessageCallback.cancelled, }); } @@ -111,6 +134,14 @@ const PostMessageCallback = struct { message: js.Value.Temp, frame: *Frame, + // Called by the scheduler if the task is dropped before it runs. `run` and + // `cancelled` are mutually exclusive, so the temp is released exactly once. + fn cancelled(ctx: *anyopaque) void { + const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); + self.message.release(); + self.deinit(); + } + fn deinit(self: *PostMessageCallback) void { self.frame._factory.destroy(self); } @@ -120,26 +151,34 @@ const PostMessageCallback = struct { defer self.deinit(); const frame = self.frame; + // The MessageEvent takes ownership of the cloned temp and releases it on + // teardown; on any path where we don't hand it over, release it here so + // it doesn't leak. if (self.port._closed) { + self.message.release(); return null; } const target = self.port.asEventTarget(); - if (frame._event_manager.hasDirectListeners(target, "message", self.port._on_message)) { - const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{ - .data = .{ .value = self.message }, - .origin = "", - .source = null, - }, frame._page) catch |err| { - log.err(.dom, "MessagePort.postMessage", .{ .err = err }); - return null; - }).asEvent(); - - frame._event_manager.dispatchDirect(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| { - log.err(.dom, "MessagePort.postMessage", .{ .err = err }); - }; + if (!frame._event_manager.hasDirectListeners(target, "message", self.port._on_message)) { + self.message.release(); + return null; } + const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = .{ .value = self.message }, + .origin = "", + .source = null, + }, frame._page) catch |err| { + self.message.release(); + log.err(.dom, "MessagePort.postMessage", .{ .err = err }); + return null; + }).asEvent(); + + frame._event_manager.dispatchDirect(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| { + log.err(.dom, "MessagePort.postMessage", .{ .err = err }); + }; + return null; } }; @@ -153,7 +192,7 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); }; - pub const postMessage = bridge.function(MessagePort.postMessage, .{}); + pub const postMessage = bridge.function(MessagePort.postMessage, .{ .dom_exception = true }); pub const start = bridge.function(MessagePort.start, .{}); pub const close = bridge.function(MessagePort.close, .{}); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 6adebfa7e..49510a081 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -623,7 +623,7 @@ pub fn close(self: *Window) void { page.session.queueFrameDestruction(frame); } -pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]const u8, transfer: ?[]const *MessagePort, frame: *Frame) !void { +pub fn postMessage(self: *Window, message: js.Value, target_origin: ?[]const u8, transfer: ?[]const *MessagePort, frame: *Frame) !void { // For now, we ignore targetOrigin checking and just dispatch the message // In a full implementation, we would validate the origin _ = target_origin; @@ -634,12 +634,35 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons const arena = try target_frame.getArena(.medium, "Window.postMessage"); errdefer target_frame.releaseArena(arena); + // StructuredSerialize runs synchronously (per spec): clone the message into + // the target window's realm now. The receiver gets a fresh, independent copy + // minted in its own realm (not the source realm's object), an unserializable + // value throws a DataCloneError to the caller, and the source-realm temp + // doesn't leak into the destination context. Mirrors Worker.postMessage. + const cloned = blk: { + var ls: js.Local.Scope = undefined; + target_frame.js.localScope(&ls); + defer ls.deinit(); + + // Contain any V8 exception from a failed serialization so it surfaces as + // a clean DataCloneError; deinit() (no rethrow) clears it. + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const c = message.structuredCloneTo(&ls.local) catch { + return error.DataClone; + }; + break :blk try c.temp(); + }; + errdefer cloned.release(); + // Origin should be the source window's origin (where the message came from) const origin = try source_window._location.getOrigin(&frame.js.execution); const callback = try arena.create(PostMessageCallback); callback.* = .{ .arena = arena, - .message = message, + .message = cloned, .frame = target_frame, .source = source_window, .origin = try arena.dupe(u8, origin), @@ -872,8 +895,11 @@ const PostMessageCallback = struct { self.frame.releaseArena(self.arena); } + // Called by the scheduler if the task is dropped before it runs. `run` and + // `cancelled` are mutually exclusive, so the temp is released exactly once. fn cancelled(ctx: *anyopaque) void { const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); + self.message.release(); self.deinit(); } @@ -885,18 +911,24 @@ const PostMessageCallback = struct { const window = frame.window; const event_target = window.asEventTarget(); - if (frame._event_manager.hasDirectListeners(event_target, "message", window._on_message)) { - const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ - .data = .{ .value = self.message }, - .origin = self.origin, - .source = self.source, - .ports = self.ports, - .bubbles = false, - .cancelable = false, - }, frame._page)).asEvent(); - try frame._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" }); + + // The MessageEvent takes ownership of the cloned temp and releases it on + // teardown; if there are no listeners, release it here so it doesn't leak. + if (!frame._event_manager.hasDirectListeners(event_target, "message", window._on_message)) { + self.message.release(); + return null; } + const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = .{ .value = self.message }, + .origin = self.origin, + .source = self.source, + .ports = self.ports, + .bubbles = false, + .cancelable = false, + }, frame._page)).asEvent(); + try frame._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" }); + return null; } }; @@ -982,7 +1014,7 @@ pub const JsApi = struct { pub const requestIdleCallback = bridge.function(Window.requestIdleCallback, .{}); pub const cancelIdleCallback = bridge.function(Window.cancelIdleCallback, .{}); pub const matchMedia = bridge.function(Window.matchMedia, .{}); - pub const postMessage = bridge.function(Window.postMessage, .{}); + pub const postMessage = bridge.function(Window.postMessage, .{ .dom_exception = true }); pub const btoa = bridge.function(Window.btoa, .{ .dom_exception = true }); pub const atob = bridge.function(Window.atob, .{ .dom_exception = true }); pub const reportError = bridge.function(Window.reportError, .{}); @@ -1069,7 +1101,7 @@ pub const JsApi = struct { const CrossOriginWindow = struct { window: *Window, - pub fn postMessage(self: *CrossOriginWindow, message: js.Value.Temp, target_origin: ?[]const u8, transfer: ?[]const *MessagePort, frame: *Frame) !void { + pub fn postMessage(self: *CrossOriginWindow, message: js.Value, target_origin: ?[]const u8, transfer: ?[]const *MessagePort, frame: *Frame) !void { return self.window.postMessage(message, target_origin, transfer, frame); } @@ -1094,7 +1126,7 @@ const CrossOriginWindow = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const postMessage = bridge.function(CrossOriginWindow.postMessage, .{}); + pub const postMessage = bridge.function(CrossOriginWindow.postMessage, .{ .dom_exception = true }); pub const top = bridge.accessor(CrossOriginWindow.getTop, null, .{}); pub const parent = bridge.accessor(CrossOriginWindow.getParent, null, .{}); pub const length = bridge.accessor(CrossOriginWindow.getFramesLength, null, .{});