fix(window): preserve browsing context opener semantics

This commit is contained in:
ldm0
2026-09-09 06:52:22 +08:00
parent 3b4b78f4dc
commit 6e14effb54
7 changed files with 433 additions and 14 deletions
@@ -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,
@@ -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 => {}
}
}
@@ -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<v8::Local<'s, v8::Object>> {
) -> 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(
@@ -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)]
@@ -41,6 +41,7 @@ struct ChildWindowProxyRecord {
facade_context: Option<v8::Global<v8::Context>>,
browsing_context_parent_window: Option<v8::Global<v8::Object>>,
browsing_context_top_window: Option<v8::Global<v8::Object>>,
browsing_context_opener: Option<ChildWindowProxyOpener>,
cross_origin_endpoint_projections:
HashMap<PendingWindowMessageEndpoint, v8::Global<v8::Object>>,
realm_top_window_wrapper: Option<v8::Global<v8::Object>>,
@@ -50,6 +51,11 @@ struct ChildWindowProxyRecord {
default_execution_context_id: Option<i64>,
}
struct ChildWindowProxyOpener {
endpoint: PendingWindowMessageEndpoint,
window: v8::Global<v8::Object>,
}
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<v8::Local<'s, v8::Object>> {
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, '_>,
@@ -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<v8::Object>,
opener: Option<super::PendingWindowMessageEndpoint>,
opener_window: Option<v8::Global<v8::Object>>,
location_url: Url,
opener_sandbox_policy: Option<DocumentSandboxPolicy>,
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<v8::Local<'s, v8::Object>> {
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<String> {
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>,
@@ -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/");