From 6e14effb54557ca8d3074c490738c08b07c16d4e Mon Sep 17 00:00:00 2001 From: ldm0 Date: Fri, 4 Sep 2026 06:11:10 +0800 Subject: [PATCH] fix(window): preserve browsing context opener semantics --- .../src/context_bootstrap/window_accessors.rs | 2 +- .../window_accessors/surface.rs | 56 ++++++- .../window_runtime/dialogs.rs | 31 +++- .../src/context_bootstrap/window_template.rs | 6 +- .../child_frame_runtime/window.rs | 102 +++++++++++++ .../src/native_bridge/context_host/popups.rs | 112 +++++++++++++- .../src/script_vm/tests/browser_api/misc.rs | 138 ++++++++++++++++++ 7 files changed, 433 insertions(+), 14 deletions(-) diff --git a/moli-renderer-v8/src/context_bootstrap/window_accessors.rs b/moli-renderer-v8/src/context_bootstrap/window_accessors.rs index 9cd5a55a76..941799b4fd 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_accessors.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_accessors.rs @@ -33,7 +33,7 @@ pub(super) use interceptors::{ pub(super) use surface::{ window_custom_elements_getter, window_device_pixel_ratio_getter, window_frames_getter, window_inner_height_getter, window_inner_surface_height, window_inner_surface_width, - window_inner_width_getter, window_navigator_getter, window_opener_getter, + window_inner_width_getter, window_navigator_getter, window_opener_getter, window_opener_setter, window_outer_height_getter, window_outer_width_getter, window_parent_getter, window_performance_getter, window_screen_getter, window_scroll_x_getter, window_scroll_y_getter, window_self_getter, window_speech_synthesis_getter, window_top_getter, diff --git a/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs b/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs index 29ff21580a..12fde4f822 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs @@ -3,6 +3,7 @@ use super::helpers::{ window_host_ptr, window_receiver, }; use super::*; +use crate::{native_bridge::lightweight_popup_id_from_window, util::v8str, webidl}; fn window_inner_surface_dimension<'s>( scope: &mut v8::PinScope<'s, '_>, @@ -124,8 +125,61 @@ pub(in crate::context_bootstrap) fn window_opener_getter<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - if window_receiver(scope, &args).is_some() { + let Some(receiver) = window_receiver(scope, &args) else { + return; + }; + if window_has_discarded_child_browsing_context(scope, receiver) { rv.set_null(); + return; + } + if let Some(popup_id) = lightweight_popup_id_from_window(scope, receiver) + && let Some(host_ptr) = window_host_ptr(scope, receiver) + && let Some(opener) = unsafe { &*host_ptr }.lightweight_popup_opener_window(scope, popup_id) + { + rv.set(opener.into()); + return; + } + if let Some(handle) = window_child_context_handle(scope, receiver) + && let Some(host_ptr) = window_host_ptr(scope, receiver) + && let Some(opener) = unsafe { &*host_ptr }.child_browsing_context_opener(scope, handle) + { + rv.set(opener.into()); + return; + } + rv.set_null(); +} + +pub(in crate::context_bootstrap) fn window_opener_setter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some(receiver) = window_receiver(scope, &args) else { + return; + }; + let value = args.get(0); + if value.is_null() { + if let Some(host_ptr) = window_host_ptr(scope, receiver) { + let host = unsafe { &mut *host_ptr }; + if let Some(popup_id) = lightweight_popup_id_from_window(scope, receiver) { + host.clear_lightweight_popup_opener(popup_id); + } else if let Some(handle) = window_child_context_handle(scope, receiver) { + host.clear_child_browsing_context_opener(handle); + } + } + return; + } + match receiver.define_own_property( + scope, + v8str(scope, "opener").into(), + value, + v8::PropertyAttribute::NONE, + ) { + Some(true) => {} + Some(false) => { + webidl::throw_type_error(scope, "Failed to replace Window.opener property."); + } + None => {} } } diff --git a/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs b/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs index 9627e920c7..3bc1c62a29 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs @@ -6,12 +6,13 @@ use crate::{ context_bootstrap::CHILD_BROWSING_CONTEXT_HANDLE_SLOT, document_runtime::{DocumentPolicyContainer, DomHandle}, native_bridge::{ - InputNavigationPolicy, OwnerDispatchScope, child_window_handle_from_marker_data, + InputNavigationPolicy, OwnerDispatchScope, PendingWindowMessageEndpoint, + child_window_handle_from_marker_data, element::{ SpecialBrowsingContextTarget, navigate_existing_browsing_context_target, navigate_named_iframe_target, }, - entered_child_window_handle, + entered_child_window_handle, lightweight_popup_id_from_window, }, runtime::{ RendererPendingJavaScriptDialog, RendererPendingPopupActivation, @@ -224,13 +225,28 @@ pub(crate) fn window_open_callback<'s>( } return; } - if let Some(target_window) = + if let Some((target_handle, target_window)) = existing_named_child_window_for_window_open(scope, host_ptr, &parsed.target_name) && !suppress_opener - && navigate_named_iframe_target(scope, host_ptr, &parsed.target_name, &url, None) { - rv.set(target_window.into()); - return; + let opener_child_handle = window_open_receiver_child_handle(scope, entered_window); + let opener_endpoint = opener_child_handle + .map(PendingWindowMessageEndpoint::ChildWindow) + .or_else(|| { + lightweight_popup_id_from_window(scope, entered_window) + .map(PendingWindowMessageEndpoint::LightweightPopup) + }) + .unwrap_or(PendingWindowMessageEndpoint::TopWindow); + unsafe { &mut *host_ptr }.set_child_browsing_context_opener( + scope, + target_handle, + opener_endpoint, + entered_window, + ); + if navigate_named_iframe_target(scope, host_ptr, &parsed.target_name, &url, None) { + rv.set(target_window.into()); + return; + } } let host = unsafe { &mut *host_ptr }; let window_open_event = RendererPendingWindowOpenEvent { @@ -460,13 +476,14 @@ fn existing_named_child_window_for_window_open<'s>( scope: &mut v8::PinScope<'s, '_>, host_ptr: *mut crate::native_bridge::JsContextHost, target_name: &str, -) -> Option> { +) -> Option<(DomHandle, v8::Local<'s, v8::Object>)> { if target_name.is_empty() || SpecialBrowsingContextTarget::parse(target_name).is_some() { return None; } let host = unsafe { &mut *host_ptr }; let handle = host.child_browsing_context_handle_by_name(target_name)?; host.child_browsing_context_window_wrapper(scope, handle) + .map(|window| (handle, window)) } fn open_dialog( diff --git a/moli-renderer-v8/src/context_bootstrap/window_template.rs b/moli-renderer-v8/src/context_bootstrap/window_template.rs index d56f3198c9..a2003fec21 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_template.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_template.rs @@ -268,7 +268,11 @@ struct WindowIdentityAccessorsDeclaration { #[derive(WebApiFunctionTemplate)] #[webapi(name = "Window", enumerable)] struct WindowPostRuntimeAccessorsDeclaration { - #[webapi(accessor_property, getter = window_opener_getter)] + #[webapi( + accessor_property, + getter = window_opener_getter, + setter = window_opener_setter + )] opener: (), #[webapi(accessor_property, getter = window_inner_width_getter)] diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs index 777e2d0b37..39d1e53ce9 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs @@ -41,6 +41,7 @@ struct ChildWindowProxyRecord { facade_context: Option>, browsing_context_parent_window: Option>, browsing_context_top_window: Option>, + browsing_context_opener: Option, cross_origin_endpoint_projections: HashMap>, realm_top_window_wrapper: Option>, @@ -50,6 +51,11 @@ struct ChildWindowProxyRecord { default_execution_context_id: Option, } +struct ChildWindowProxyOpener { + endpoint: PendingWindowMessageEndpoint, + window: v8::Global, +} + impl ChildWindowProxyRecords { fn record_mut(&mut self, handle: DomHandle) -> &mut ChildWindowProxyRecord { self.records.entry(handle).or_default() @@ -161,6 +167,41 @@ impl ChildWindowProxyRecords { .map(|top| v8::Local::new(scope, top)) } + pub(in crate::native_bridge::context_host) fn set_browsing_context_opener( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + handle: DomHandle, + endpoint: PendingWindowMessageEndpoint, + window: v8::Local<'_, v8::Object>, + ) { + self.record_mut(handle).browsing_context_opener = Some(ChildWindowProxyOpener { + endpoint, + window: v8::Global::new(scope, window), + }); + } + + pub(in crate::native_bridge::context_host) fn clear_browsing_context_opener( + &mut self, + handle: DomHandle, + ) { + if let Some(record) = self.records.get_mut(&handle) { + record.browsing_context_opener = None; + } + } + + pub(in crate::native_bridge::context_host) fn browsing_context_opener<'s>( + &self, + scope: &mut v8::PinScope<'s, '_, ()>, + handle: DomHandle, + ) -> Option<(PendingWindowMessageEndpoint, v8::Local<'s, v8::Object>)> { + let opener = self + .records + .get(&handle)? + .browsing_context_opener + .as_ref()?; + Some((opener.endpoint, v8::Local::new(scope, &opener.window))) + } + fn cross_origin_endpoint_projection<'s>( &self, scope: &mut v8::PinScope<'s, '_>, @@ -1285,6 +1326,67 @@ impl JsContextHost { self.child_window_proxy_records.realm_top(scope, handle) } + pub(crate) fn set_child_browsing_context_opener<'s>( + &mut self, + scope: &mut v8::PinScope<'s, '_>, + handle: DomHandle, + endpoint: PendingWindowMessageEndpoint, + opener: v8::Local<'s, v8::Object>, + ) { + if !self.child_browsing_context_is_live(handle) { + return; + } + self.child_window_proxy_records + .set_browsing_context_opener(scope, handle, endpoint, opener); + } + + pub(crate) fn clear_child_browsing_context_opener(&mut self, handle: DomHandle) { + self.child_window_proxy_records + .clear_browsing_context_opener(handle); + } + + pub(crate) fn child_browsing_context_opener<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + handle: DomHandle, + ) -> Option> { + let (endpoint, opener) = self + .child_window_proxy_records + .browsing_context_opener(scope, handle)?; + self.window_opener_endpoint_is_live(scope, endpoint, opener) + .then_some(opener) + } + + pub(in crate::native_bridge::context_host) fn window_opener_endpoint_is_live<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + endpoint: PendingWindowMessageEndpoint, + opener: v8::Local<'s, v8::Object>, + ) -> bool { + match endpoint { + PendingWindowMessageEndpoint::TopWindow => true, + PendingWindowMessageEndpoint::ChildWindow(opener_handle) => { + if !self.child_browsing_context_is_live(opener_handle) { + false + } else { + opener + .get_creation_context(scope) + .and_then(|context| { + self.window_execution_context_identity_for_access_check(context) + }) + .is_some_and(|identity| { + identity.dispatch_scope() + == super::super::OwnerDispatchScope::Child(opener_handle) + && self.window_execution_context_identity_is_current(identity) + }) + } + } + PendingWindowMessageEndpoint::LightweightPopup(popup_id) => { + self.lightweight_popup_is_open(popup_id) + } + } + } + pub(in crate::native_bridge::context_host) fn child_browsing_context_parent_window<'s>( &mut self, scope: &mut v8::PinScope<'s, '_>, diff --git a/moli-renderer-v8/src/native_bridge/context_host/popups.rs b/moli-renderer-v8/src/native_bridge/context_host/popups.rs index 43a4baabc7..ad127ac799 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs @@ -173,6 +173,21 @@ struct LightweightPopupWindowNameDeclaration { name: (), } +#[derive(WebApiObject)] +#[webapi(interface = "Object")] +struct LightweightPopupWindowOpenerDeclaration<'scope> { + popup_id: v8::Local<'scope, v8::BigInt>, + #[webapi( + accessor_property, + enumerable, + getter = lightweight_popup_window_opener_getter, + setter = lightweight_popup_window_opener_setter, + data = self.popup_id, + setter_data = self.popup_id + )] + opener: (), +} + #[derive(WebApiObject)] #[webapi(interface = "Object")] struct LightweightPopupComputedStyleMethodDeclaration<'scope> { @@ -424,6 +439,7 @@ enum LightweightPopupLifecycle { pub(super) struct LightweightPopupBrowsingContextRecord { window_proxy: v8::Global, opener: Option, + opener_window: Option>, location_url: Url, opener_sandbox_policy: Option, lifecycle: LightweightPopupLifecycle, @@ -610,6 +626,7 @@ impl JsContextHost { .expect("lightweight popup navigation id space exhausted"), ); record.opener = None; + record.opener_window = None; Some(LightweightPopupCloseTransition { retired_owner: open.document.owner, retired_local_window_id: open.document.local_window_id, @@ -760,6 +777,12 @@ impl JsContextHost { LightweightPopupWindowNameDeclaration::default() .initialize(scope, window) .ok()?; + LightweightPopupWindowOpenerDeclaration { + popup_id: popup_id_private_value, + opener: (), + } + .initialize(scope, window) + .ok()?; install_window_location_history_navigation_runtime_state( scope, window, @@ -784,11 +807,7 @@ impl JsContextHost { set_object_slot(scope, window, "top", window.into()); set_object_slot(scope, window, "frames", window.into()); if let Some(opener) = opener { - set_object_slot(scope, window, "opener", opener.into()); install_lightweight_popup_viewport_surface_from_opener(scope, opener, window); - } else { - let opener = v8::null(scope); - set_object_slot(scope, window, "opener", opener.into()); } if let Ok(navigator) = crate::context_bootstrap::build_lightweight_popup_window_navigator_object( @@ -855,6 +874,7 @@ impl JsContextHost { LightweightPopupBrowsingContextRecord { window_proxy: v8::Global::new(scope, window), opener: opener_endpoint, + opener_window: opener.map(|opener| v8::Global::new(scope, opener)), location_url: initial_url.clone(), opener_sandbox_policy, lifecycle: LightweightPopupLifecycle::Open(Box::new(LightweightPopupOpenState { @@ -1090,6 +1110,32 @@ impl JsContextHost { .and_then(|record| record.opener) } + pub(crate) fn lightweight_popup_opener_window<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + popup_id: u64, + ) -> Option> { + let (endpoint, opener) = { + let record = self.lightweight_popup_browsing_contexts.get(&popup_id)?; + if !record.is_open() { + return None; + } + let endpoint = record.opener?; + let opener = v8::Local::new(scope, record.opener_window.as_ref()?); + (endpoint, opener) + }; + self.window_opener_endpoint_is_live(scope, endpoint, opener) + .then_some(opener) + } + + pub(crate) fn clear_lightweight_popup_opener(&mut self, popup_id: u64) { + let Some(record) = self.lightweight_popup_browsing_contexts.get_mut(&popup_id) else { + return; + }; + record.opener = None; + record.opener_window = None; + } + pub(crate) fn lightweight_popup_origin(&self, popup_id: u64) -> Option { self.lightweight_popup_access_origin(popup_id) .map(|origin| origin.serialized_origin()) @@ -4254,6 +4300,64 @@ fn lightweight_popup_window_name_setter<'s>( } } +fn lightweight_popup_window_opener_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some(popup_id) = lightweight_popup_id_from_value(scope, args.data()) else { + throw_type_error( + scope, + "Window.opener getter called with invalid popup data.", + ); + return; + }; + let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { + rv.set_null(); + return; + }; + match unsafe { &*host_ptr }.lightweight_popup_opener_window(scope, popup_id) { + Some(opener) => rv.set(opener.into()), + None => rv.set_null(), + } +} + +fn lightweight_popup_window_opener_setter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some(popup_id) = lightweight_popup_id_from_value(scope, args.data()) else { + throw_type_error( + scope, + "Window.opener setter called with invalid popup data.", + ); + return; + }; + let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { + return; + }; + let host = unsafe { &mut *host_ptr }; + let value = args.get(0); + if value.is_null() { + host.clear_lightweight_popup_opener(popup_id); + return; + } + let Some(window) = host.lightweight_popup_window(scope, popup_id) else { + return; + }; + match window.define_own_property( + scope, + v8str(scope, "opener").into(), + value, + v8::PropertyAttribute::NONE, + ) { + Some(true) => {} + Some(false) => throw_type_error(scope, "Failed to replace Window.opener property."), + None => {} + } +} + fn lightweight_popup_close_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs index 11bb5662cf..7b638fe528 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs @@ -25222,6 +25222,79 @@ fn window_open_resolves_relative_urls_against_the_entry_function_realm() { })); } +#[test] +fn window_open_existing_named_child_sets_and_can_disown_its_opener() { + let mut vm = new_storage_test_vm("https://example.com/"); + + vm.eval( + r#" +(() => { + const frame = document.createElement("iframe"); + frame.name = "named-child"; + (document.body || document.documentElement || document).appendChild(frame); + globalThis.__namedOpenerFrame = frame; + return "created"; +})() +"#, + ) + .expect("named child opener setup should evaluate"); + vm.drain_pending_child_frame_work_for_test(); + + let result = vm + .eval( + r#" +(() => { + const child = __namedOpenerFrame.contentWindow; + const before = child.opener; + const opened = open("about:blank#opened", "named-child"); + const descriptor = Object.getOwnPropertyDescriptor(child, "opener"); + const openerGet = descriptor.get; + const openerSet = descriptor.set; + const selected = { + initiallyNull: before === null, + returnedExisting: opened === child, + openerIsTop: child.opener === window, + directGetterIsTop: openerGet() === window, + descriptorShape: [ + typeof openerGet, + typeof openerSet, + descriptor.enumerable, + descriptor.configurable + ].join(":") + }; + + child.opener = null; + const disowned = { + openerIsNull: child.opener === null, + directGetterIsNull: openerGet() === null, + descriptorPreserved: + Object.getOwnPropertyDescriptor(child, "opener").get === openerGet + }; + + child.opener = "immaterial"; + const replacement = Object.getOwnPropertyDescriptor(child, "opener"); + return JSON.stringify({ + selected, + disowned, + replaced: { + value: child.opener, + directGetterIsNull: openerGet() === null, + writable: replacement.writable, + enumerable: replacement.enumerable, + configurable: replacement.configurable + } + }); +})() +"#, + ) + .expect("named child opener semantics should evaluate"); + + assert_eq!( + result, + r#"{"selected":{"initiallyNull":true,"returnedExisting":true,"openerIsTop":true,"directGetterIsTop":true,"descriptorShape":"function:function:true:true"},"disowned":{"openerIsNull":true,"directGetterIsNull":true,"descriptorPreserved":true},"replaced":{"value":"immaterial","directGetterIsNull":true,"writable":true,"enumerable":true,"configurable":true}}"# + ); +} + #[test] fn window_open_about_blank_returns_lightweight_popup_window() { let mut vm = new_storage_test_vm("https://example.com/"); @@ -25270,6 +25343,71 @@ fn window_open_about_blank_returns_lightweight_popup_window() { ); } +#[test] +fn lightweight_popup_opener_accessor_preserves_and_disowns_the_underlying_relation() { + let mut vm = new_storage_test_vm("https://example.com/"); + + let result = vm + .eval( + r#" +(() => { + const topOpenerGet = Object.getOwnPropertyDescriptor(window, "opener").get; + const replaced = open(); + const replacedDescriptor = Object.getOwnPropertyDescriptor(replaced, "opener"); + const replacedGet = replacedDescriptor.get; + replaced.opener = "replacement"; + const replacementDescriptor = Object.getOwnPropertyDescriptor(replaced, "opener"); + + const disowned = open(); + const disownedDescriptor = Object.getOwnPropertyDescriptor(disowned, "opener"); + const disownedGet = disownedDescriptor.get; + disowned.opener = null; + const descriptorAfterNull = Object.getOwnPropertyDescriptor(disowned, "opener"); + + const closed = open(); + const closedGet = Object.getOwnPropertyDescriptor(closed, "opener").get; + closed.close(); + closed.opener = "closed replacement"; + + return JSON.stringify({ + replaced: { + accessorShape: [ + typeof replacedGet, + typeof replacedDescriptor.set, + replacedDescriptor.writable, + replacedDescriptor.enumerable, + replacedDescriptor.configurable + ].join(":"), + value: replaced.opener, + boundGetterKeepsRelation: replacedGet() === window, + borrowedGetterKeepsRelation: topOpenerGet.call(replaced) === window, + dataShape: [ + replacementDescriptor.writable, + replacementDescriptor.enumerable, + replacementDescriptor.configurable + ].join(":") + }, + disowned: { + valueIsNull: disowned.opener === null, + boundGetterIsNull: disownedGet() === null, + accessorPreserved: descriptorAfterNull.get === disownedGet + }, + closed: { + boundGetterIsNull: closedGet() === null, + value: closed.opener + } + }); +})() +"#, + ) + .expect("lightweight popup opener accessor semantics should evaluate"); + + assert_eq!( + result, + r#"{"replaced":{"accessorShape":"function:function::true:true","value":"replacement","boundGetterKeepsRelation":true,"borrowedGetterKeepsRelation":true,"dataShape":"true:true:true"},"disowned":{"valueIsNull":true,"boundGetterIsNull":true,"accessorPreserved":true},"closed":{"boundGetterIsNull":true,"value":"closed replacement"}}"# + ); +} + #[test] fn window_open_blank_targets_expose_mutable_empty_browsing_context_names() { let mut vm = new_storage_test_vm("https://example.com/");