From 9ae23bd61a88b218bdffe3a8012b8aa5b6bac8a3 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:01:01 +0800 Subject: [PATCH] fix(window): preserve native named-context identity --- .../classic/extracted/windows_and_popups.rs | 452 ++++++++++++++++++ .../src/domains/target/tests/test_support.rs | 11 +- .../tests/tests_background_staging/runtime.rs | 22 + .../target/tests/tests_target_creation.rs | 149 +++++- moli-renderer-v8/src/context_bootstrap.rs | 2 +- .../src/context_bootstrap/runtime_state.rs | 84 +++- .../src/context_bootstrap/window_receiver.rs | 14 +- .../window_runtime/dialogs.rs | 56 ++- .../src/native_bridge/bindings.rs | 9 + .../child_frame_runtime/window.rs | 4 +- .../src/native_bridge/context_host/mod.rs | 2 +- .../src/native_bridge/context_host/popups.rs | 93 ++-- .../context_host/popups/renderer_owned.rs | 21 +- .../context_host/security_policy.rs | 5 + .../context_host/window_execution_context.rs | 4 +- .../operation_receiver.rs | 2 +- .../element/activation/targets.rs | 64 ++- moli-renderer-v8/src/native_bridge/mod.rs | 2 +- .../browser_api/events_selection_storage.rs | 4 +- .../window_security_and_domain.rs | 76 ++- .../misc/extracted/popup_window.rs | 271 +++++++++++ .../misc/extracted/window_surface.rs | 108 +++++ .../navigation/element_navigation.rs | 40 ++ 23 files changed, 1352 insertions(+), 143 deletions(-) diff --git a/moli-protocol-server/src/protocol_server/tests/classic/extracted/windows_and_popups.rs b/moli-protocol-server/src/protocol_server/tests/classic/extracted/windows_and_popups.rs index c4dc0fe97ac..6cdc4135796 100644 --- a/moli-protocol-server/src/protocol_server/tests/classic/extracted/windows_and_popups.rs +++ b/moli-protocol-server/src/protocol_server/tests/classic/extracted/windows_and_popups.rs @@ -730,6 +730,387 @@ async fn webdriver_classic_window_open_self_click_waits_for_current_url() { ) .await; } + +#[tokio::test] +async fn webdriver_classic_keeps_all_live_popup_proxy_aliases() { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"] + .as_str() + .expect("classic session id"); + let window_path = format!("/session/{session_id}/window"); + let execute_path = format!("/session/{session_id}/execute/sync"); + let original = classic_request_json(app.clone(), Method::GET, &window_path).await; + let original = original["value"].as_str().unwrap().to_owned(); + + let created = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "window.__oldReport = open('about:blank#report', 'report'); const helper = open('about:blank#helper', 'helper'); return [window.__oldReport, helper];", + "args": [] + }), + ) + .await; + let report = created["value"][0][CLASSIC_WINDOW_REFERENCE_KEY] + .as_str() + .expect("report handle") + .to_owned(); + let helper = created["value"][1][CLASSIC_WINDOW_REFERENCE_KEY] + .as_str() + .expect("helper handle") + .to_owned(); + + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": helper }), + ) + .await, + json!({ "value": null }) + ); + let second_alias = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "window.__newReport = open('about:blank#next', 'report'); return window.__newReport;", + "args": [] + }), + ) + .await; + assert_eq!( + second_alias["value"][CLASSIC_WINDOW_REFERENCE_KEY], + json!(report) + ); + + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": original }), + ) + .await, + json!({ "value": null }) + ); + let old_alias = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ "script": "return window.__oldReport;", "args": [] }), + ) + .await; + assert_eq!( + old_alias["value"][CLASSIC_WINDOW_REFERENCE_KEY], + json!(report) + ); + + let handles = classic_request_json( + app.clone(), + Method::GET, + &format!("/session/{session_id}/window/handles"), + ) + .await; + assert_eq!( + handles["value"] + .as_array() + .unwrap() + .iter() + .filter(|handle| **handle == json!(report)) + .count(), + 1 + ); + + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": report }), + ) + .await, + json!({ "value": null }) + ); + let remaining = classic_request_json(app.clone(), Method::DELETE, &window_path).await; + assert_eq!(remaining["value"].as_array().map(Vec::len), Some(2)); + + for (owner, proxy) in [(&original, "__oldReport"), (&helper, "__newReport")] { + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": owner }), + ) + .await, + json!({ "value": null }) + ); + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": format!("return window.{proxy}.closed;"), + "args": [] + }), + ) + .await, + json!({ "value": true }), + "every live alias must observe the shared target close" + ); + } + + let _ = classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; +} + +#[tokio::test] +async fn webdriver_classic_noopener_reuses_a_related_named_popup() { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"] + .as_str() + .expect("classic session id"); + let window_path = format!("/session/{session_id}/window"); + let handles_path = format!("/session/{session_id}/window/handles"); + let execute_path = format!("/session/{session_id}/execute/sync"); + + let created = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "window.__report = open('about:blank#first', 'report'); return window.__report;", + "args": [] + }), + ) + .await; + let report = created["value"][CLASSIC_WINDOW_REFERENCE_KEY] + .as_str() + .expect("report handle") + .to_owned(); + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "return window.__report.opener === window;", + "args": [] + }), + ) + .await, + json!({ "value": true }), + "the original popup proxy must retain its opener" + ); + let before = classic_request_json(app.clone(), Method::GET, &handles_path).await; + + let reused = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "return open('about:blank#second', 'report', 'noopener') === null;", + "args": [] + }), + ) + .await; + assert_eq!(reused, json!({ "value": true })); + assert_eq!( + classic_request_json(app.clone(), Method::GET, &handles_path).await, + before, + "noopener must not create a new target when a related named target exists" + ); + + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": report }), + ) + .await, + json!({ "value": null }) + ); + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "return location.href;", + "args": [] + }), + ) + .await, + json!({ "value": "about:blank#second" }), + "the selected target must receive the noopener navigation" + ); + + let original = before["value"] + .as_array() + .and_then(|handles| { + handles.iter().find_map(|handle| { + let handle = handle.as_str()?; + (handle != report).then(|| handle.to_owned()) + }) + }) + .expect("original window handle"); + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": original }), + ) + .await, + json!({ "value": null }) + ); + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "return window.__report.opener === window;", + "args": [] + }), + ) + .await, + json!({ "value": true }), + "reusing with noopener must not clear the existing proxy's opener" + ); + + let _ = classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; +} + +#[tokio::test] +async fn webdriver_classic_close_retires_popup_aliases_before_named_reopen() { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"] + .as_str() + .expect("classic session id"); + let window_path = format!("/session/{session_id}/window"); + let execute_path = format!("/session/{session_id}/execute/sync"); + let original = classic_request_json(app.clone(), Method::GET, &window_path).await; + let original = original["value"].as_str().unwrap().to_owned(); + + let created = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "window.__oldReport = open('about:blank#one', 'report'); return window.__oldReport;", + "args": [] + }), + ) + .await; + let old_report = created["value"][CLASSIC_WINDOW_REFERENCE_KEY] + .as_str() + .expect("old report handle") + .to_owned(); + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": old_report }), + ) + .await, + json!({ "value": null }) + ); + let remaining = classic_request_json(app.clone(), Method::DELETE, &window_path).await; + assert_eq!(remaining, json!({ "value": [original.clone()] })); + + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": original }), + ) + .await, + json!({ "value": null }) + ); + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "return window.__oldReport.closed;", + "args": [] + }), + ) + .await, + json!({ "value": true }), + "a proxy must observe that its protocol target was closed" + ); + let replacement = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "window.__newReport = open('about:blank#two', 'report'); return window.__newReport;", + "args": [] + }), + ) + .await; + let new_report = replacement["value"][CLASSIC_WINDOW_REFERENCE_KEY] + .as_str() + .expect("replacement report handle") + .to_owned(); + assert_ne!(new_report, old_report); + + let reused = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "window.__newReport.marker = 19; window.__newReport.document.body.textContent = 'kept'; for (let i = 0; i < 20; ++i) { if (open('', 'report') !== window.__newReport) return [false, i]; } return [true, window.__newReport.marker, window.__newReport.document.body.textContent, window.__newReport.closed];", + "args": [] + }), + ) + .await; + assert_eq!(reused, json!({ "value": [true, 19, "kept", false] })); + let handles = classic_request_json( + app.clone(), + Method::GET, + &format!("/session/{session_id}/window/handles"), + ) + .await; + assert_eq!(handles["value"].as_array().map(Vec::len), Some(2)); + + let _ = classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; +} + +#[tokio::test] +async fn webdriver_classic_empty_parent_ignores_replaced_public_parent_property() { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"] + .as_str() + .expect("classic session id"); + let result = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/execute/sync"), + json!({ + "script": "const fake = {}; let reads = 0; Object.defineProperty(window, 'parent', { configurable: true, get() { ++reads; return fake; } }); const selected = open('', '_parent'); return [selected === window, selected === fake, reads];", + "args": [] + }), + ) + .await; + assert_eq!(result, json!({ "value": [true, false, 0] })); + + let _ = classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; +} + #[tokio::test] async fn webdriver_classic_named_popup_reuse_navigates_existing_window() { let app = build_router(test_state()); @@ -2581,3 +2962,74 @@ async fn webdriver_classic_new_window_user_prompt_behavior_matches_chromium_wpt( .await; } } +#[tokio::test] +async fn webdriver_classic_named_popup_does_not_reuse_an_independent_tab() { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"] + .as_str() + .expect("classic session id"); + let window_path = format!("/session/{session_id}/window"); + let handles_path = format!("/session/{session_id}/window/handles"); + let execute_path = format!("/session/{session_id}/execute/sync"); + + let original = classic_request_json(app.clone(), Method::GET, &window_path).await; + let original = original["value"] + .as_str() + .expect("original window handle") + .to_owned(); + let named = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "window.name = 'independent-report'; return window.name;", + "args": [] + }), + ) + .await; + assert_eq!(named, json!({ "value": "independent-report" })); + + let independent = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/window/new"), + json!({ "type": "tab" }), + ) + .await; + let independent = independent["value"]["handle"] + .as_str() + .expect("independent tab handle") + .to_owned(); + let switched = classic_request_json_with_body( + app.clone(), + Method::POST, + &window_path, + json!({ "handle": independent }), + ) + .await; + assert_eq!(switched, json!({ "value": null })); + + let opened = classic_request_json_with_body( + app.clone(), + Method::POST, + &execute_path, + json!({ + "script": "const popup = window.open('about:blank#independent-popup', 'independent-report'); return popup !== null;", + "args": [] + }), + ) + .await; + assert_eq!(opened, json!({ "value": true })); + let handles = classic_request_json(app.clone(), Method::GET, &handles_path).await; + let handles = handles["value"].as_array().expect("window handles"); + assert_eq!( + handles.len(), + 3, + "an unrelated same-name tab must not be selected as the popup target: {handles:?}" + ); + assert!(handles.contains(&json!(original))); + assert!(handles.contains(&json!(independent))); + + let _ = classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; +} diff --git a/moli-protocol/src/domains/target/tests/test_support.rs b/moli-protocol/src/domains/target/tests/test_support.rs index e04947b5ee4..826e3db8469 100644 --- a/moli-protocol/src/domains/target/tests/test_support.rs +++ b/moli-protocol/src/domains/target/tests/test_support.rs @@ -207,6 +207,15 @@ pub(super) async fn load_bc_with_titled_page_async( bc_id: &str, target_id: &str, html: &str, +) { + load_bc_with_page_url_async(ctx, bc_id, target_id, &format!("data:text/html,{html}")).await; +} + +pub(super) async fn load_bc_with_page_url_async( + ctx: &mut TestContext, + bc_id: &str, + target_id: &str, + url: &str, ) { // Most tests that use this helper assert Target-domain discovery output // after manually seeding a loaded target. Real CDP clients only receive @@ -219,7 +228,7 @@ pub(super) async fn load_bc_with_titled_page_async( ctx.conn.insert_browser_context(bc); let page = ctx .conn - .load_page_via_runtime_async(&format!("data:text/html,{html}")) + .load_page_via_runtime_async(url) .await .expect("page should load"); let renderer_page = crate::conn::RendererPageResidenceIdentity::from_page(&page); diff --git a/moli-protocol/src/domains/target/tests/tests_background_staging/runtime.rs b/moli-protocol/src/domains/target/tests/tests_background_staging/runtime.rs index b90445054c1..c84c36bbb3d 100644 --- a/moli-protocol/src/domains/target/tests/tests_background_staging/runtime.rs +++ b/moli-protocol/src/domains/target/tests/tests_background_staging/runtime.rs @@ -575,6 +575,28 @@ async fn same_context_named_popup_reuse_navigates_and_activates_loaded_owner() { .await; let response = take_response_by_id(&mut ctx, 1041949447); assert_eq!(response["result"]["result"]["value"], json!("named target")); + + ctx.process_async(json!({ + "id": 1041949448, + "method": "Runtime.evaluate", + "sessionId": owner.session_id, + "params": { + "expression": "window.name" + } + })) + .await; + let response = take_response_by_id(&mut ctx, 1041949448); + assert_eq!( + response["result"]["result"]["value"], + json!("reportWindow"), + "a reused named target must retain its browsing-context name across Document replacement" + ); + let browser_context = ctx.conn.browser_context.as_ref().expect("browser context"); + assert_eq!( + browser_context.target_id_for_window_name("reportWindow"), + Some(owner.target_id.as_str()), + "the popup proxy name and the live target engine must share one browsing-context owner" + ); }) .await; } diff --git a/moli-protocol/src/domains/target/tests/tests_target_creation.rs b/moli-protocol/src/domains/target/tests/tests_target_creation.rs index 66dcda99f27..f48216a1dba 100644 --- a/moli-protocol/src/domains/target/tests/tests_target_creation.rs +++ b/moli-protocol/src/domains/target/tests/tests_target_creation.rs @@ -1895,6 +1895,23 @@ async fn popup_initial_navigation_replaces_internal_blank_history_entry() { #[tokio::test(flavor = "multi_thread")] async fn window_open_named_target_reuses_existing_popup_target() { + // Rename access requires a same-origin WindowProxy. Separate HTTP paths + // retain full document navigations; data: documents have opaque origins. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .fallback(|| async { ([(CONTENT_TYPE, "text/html")], "
named popup
") }), + ) + .await + .unwrap(); + }); + let opener_url = format!("http://{address}/opener"); + let first_url = format!("http://{address}/first-popup"); + let second_url = format!("http://{address}/second-popup"); + let renamed_url = format!("http://{address}/renamed-popup"); let mut ctx = TestContext::new(); // Target creation/named-target selection is synchronous with // `window.open()`, but fetching the selected target's URL is not. Mirror @@ -1903,11 +1920,11 @@ async fn window_open_named_target_reuses_existing_popup_target() { ctx.enable_background_navigation_scheduler_for_test(); tokio::task::LocalSet::new() .run_until(async { - load_bc_with_titled_page_async( + load_bc_with_page_url_async( &mut ctx, "BID-popup-name", "TID-opener-name", - "
popup opener
", + &opener_url, ) .await; ctx.process_async(json!({ @@ -1935,7 +1952,7 @@ async fn window_open_named_target_reuses_existing_popup_target() { "method": "Runtime.evaluate", "sessionId": opener_session_id, "params": { - "expression": "window.open('data:text/html,first-popup', 'reportWindow') !== null" + "expression": format!("window.__namedPopup = window.open({}, 'reportWindow'); window.__namedPopup !== null", json!(first_url)) } })) .await; @@ -1951,7 +1968,7 @@ async fn window_open_named_target_reuses_existing_popup_target() { .to_owned(); assert_eq!( created["params"]["targetInfo"]["url"], - json!("data:text/html,first-popup") + json!(first_url) ); assert!( first_sent @@ -1959,13 +1976,12 @@ async fn window_open_named_target_reuses_existing_popup_target() { .any(|message| message["method"] == json!("Page.windowOpen")), "first named window.open should emit Page.windowOpen: {first_sent:?}" ); - ctx.process_async(json!({ "id": 15, "method": "Runtime.evaluate", "sessionId": opener_session_id, "params": { - "expression": "window.open('data:text/html,second-popup', 'reportWindow') !== null" + "expression": format!("window.open({}, 'reportWindow') !== null", json!(second_url)) } })) .await; @@ -1994,7 +2010,7 @@ async fn window_open_named_target_reuses_existing_popup_target() { && message["params"]["targetInfo"]["targetId"] == json!(target_id) && message["params"]["targetInfo"]["url"] - == json!("data:text/html,second-popup") + == json!(second_url) }, ) .await; @@ -2004,7 +2020,7 @@ async fn window_open_named_target_reuses_existing_popup_target() { ); assert_eq!( changed["params"]["targetInfo"]["url"], - json!("data:text/html,second-popup") + json!(second_url) ); ctx.wait_until_scheduler_state("reused named popup remains selected", |conn| { conn.browser_context_by_id("BID-popup-name") @@ -2012,7 +2028,7 @@ async fn window_open_named_target_reuses_existing_popup_target() { browser_context.active_target_id() == Some(target_id.as_str()) && loaded_page_for_target(browser_context, &target_id).is_some_and( |page| { - page.final_url().as_str() == "data:text/html,second-popup" + page.final_url().as_str() == second_url }, ) }) @@ -2022,12 +2038,123 @@ async fn window_open_named_target_reuses_existing_popup_target() { assert_eq!(browser_context.active_target_id(), Some(target_id.as_str())); assert_eq!( browser_context.target_url(), - "data:text/html,second-popup" + second_url + ); + ctx.process_async(json!({ + "id": 141, + "method": "Target.attachToTarget", + "params": { "targetId": target_id } + })) + .await; + let popup_session_id = take_response_by_id(&mut ctx, 141)["result"]["sessionId"] + .as_str() + .expect("popup session id") + .to_owned(); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 16, + "method": "Runtime.evaluate", + "sessionId": opener_session_id, + "params": { + "expression": "window.__namedPopup.name = 'renamedWindow'; window.__namedPopup.name" + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 16)["result"]["result"]["value"], + json!("renamedWindow") + ); + ctx.process_async(json!({ + "id": 161, + "method": "Runtime.evaluate", + "sessionId": popup_session_id, + "params": { "expression": "window.name" } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 161)["result"]["result"]["value"], + json!("renamedWindow"), + "a proxy rename must be immediately visible in the target" + ); + ctx.process_async(json!({ + "id": 162, + "method": "Runtime.evaluate", + "sessionId": opener_session_id, + "params": { + "expression": format!("window.open({}, 'renamedWindow') !== null", json!(renamed_url)) + } + })) + .await; + let renamed_sent = ctx.take_all(); + assert!( + !renamed_sent + .iter() + .any(|message| message["method"] == json!("Target.targetCreated")), + "opening a popup by its live renamed Window.name must reuse its bound target: {renamed_sent:?}" + ); + ctx.wait_for_scheduler_message("renamed popup target navigation", |message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["targetId"] == json!(target_id) + && message["params"]["targetInfo"]["url"] + == json!(renamed_url) + }) + .await; + let browser_context = ctx.conn.browser_context.as_ref().unwrap(); + assert_eq!( + browser_context.target_id_for_window_name("renamedWindow"), + Some(target_id.as_str()) + ); + assert_eq!( + browser_context.target_id_for_window_name("reportWindow"), + None, + "renaming a live popup must invalidate its previous target name" + ); + + ctx.process_async(json!({ + "id": 163, + "method": "Runtime.evaluate", + "sessionId": popup_session_id, + "params": { "expression": "window.name = 'targetRenamed'; window.name" } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 163)["result"]["result"]["value"], + json!("targetRenamed") + ); + ctx.process_async(json!({ + "id": 164, + "method": "Runtime.evaluate", + "sessionId": opener_session_id, + "params": { "expression": "window.__namedPopup.name" } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 164)["result"]["result"]["value"], + json!("targetRenamed"), + "a target rename must be immediately visible through its retained proxy" + ); + + ctx.process_async(json!({ + "id": 17, + "method": "Runtime.evaluate", + "sessionId": opener_session_id, + "params": { + "expression": "window.open('about:blank', 'renamedWindow') !== null" + } + })) + .await; + let old_name_sent = ctx.take_all(); + assert!( + old_name_sent + .iter() + .any(|message| message["method"] == json!("Target.targetCreated")), + "the target's previous name must no longer resolve to it: {old_name_sent:?}" ); }) .await; + server.abort(); } - #[tokio::test(flavor = "multi_thread")] async fn window_open_named_target_reused_in_same_command_emits_one_page_event() { let mut ctx = TestContext::new(); diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 19477d5d26b..31ae7d466d4 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -203,7 +203,7 @@ pub(crate) use window_accessors::{ mod window_events; mod window_lazy_surface; mod window_receiver; -pub(crate) use window_receiver::is_window_receiver; +pub(crate) use window_receiver::{NATIVE_WINDOW_BRAND_SLOT, is_window_receiver}; mod window_runtime; pub(crate) use form_data_runtime::{ diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs index 0573a0d1d0e..f03e49d423c 100644 --- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs @@ -871,26 +871,59 @@ fn window_surface_replaceable_setter<'s>( define_replaceable_window_property(scope, receiver, name, args.get(0)); } +fn window_name_receiver_owner<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> Option<(*mut JsContextHost, native_bridge::OwnerDispatchScope)> { + if !super::is_window_receiver(scope, receiver) { + return None; + } + let host_ptr = crate::util::context_host_ptr_from_window_object(scope, receiver) + .or_else(|| { + receiver + .get_creation_context(scope) + .and_then(crate::util::context_host_ptr_from_context_slot) + }) + .or_else(|| { + receiver + .strict_equals(scope.get_current_context().global(scope).into()) + .then(|| context_host_ptr_from_global_bridge(scope)) + .flatten() + })?; + let owner = super::navigation_window::runtime_window_dispatch_scope(scope, receiver) + .unwrap_or(native_bridge::OwnerDispatchScope::Top); + Some((host_ptr, owner)) +} + fn window_name_runtime_getter<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { let receiver = callback_this_object(scope, &args); - if child_context_handle_from_owner(scope, receiver).is_none() - && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) - { - let host = unsafe { &*host_ptr }; - let name = if host.browsing_context_is_closed() { - String::new() - } else { - host.browsing_context_name().get() - }; - rv.set(v8::String::new(scope, &name).unwrap().into()); + let Some((host_ptr, owner)) = window_name_receiver_owner(scope, receiver) else { + throw_type_error(scope, "Window.name getter called on incompatible receiver."); return; + }; + let host = unsafe { &*host_ptr }; + let value = match owner { + native_bridge::OwnerDispatchScope::Top => { + let name = if host.browsing_context_is_closed() { + String::new() + } else { + host.browsing_context_name().get() + }; + v8_string(scope, &name).map(v8::Local::::from) + } + native_bridge::OwnerDispatchScope::Child(_) => { + object_hidden_value(scope, receiver, WINDOW_NAME_SLOT) + } + native_bridge::OwnerDispatchScope::LightweightPopup(popup_id) => host + .lightweight_popup_window_name(popup_id) + .and_then(|name| v8_string(scope, &name)) + .map(v8::Local::::from), } - let value = object_hidden_value(scope, receiver, WINDOW_NAME_SLOT) - .unwrap_or_else(|| v8::String::empty(scope).into()); + .unwrap_or_else(|| v8::String::empty(scope).into()); rv.set(value); } @@ -900,21 +933,28 @@ fn window_name_runtime_setter<'s>( _rv: v8::ReturnValue<'s, v8::Value>, ) { let receiver = callback_this_object(scope, &args); - let next = args - .get(0) - .to_string(scope) - .map(|value| value.to_rust_string_lossy(scope)) - .unwrap_or_default(); - if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { - let host = unsafe { &mut *host_ptr }; - if let Some(handle) = child_context_handle_from_owner(scope, receiver) { - host.set_child_browsing_context_name(handle, next.clone()); - } else { + let Some((host_ptr, owner)) = window_name_receiver_owner(scope, receiver) else { + throw_type_error(scope, "Window.name setter called on incompatible receiver."); + return; + }; + let Some(next) = args.get(0).to_string(scope) else { + return; + }; + let next = next.to_rust_string_lossy(scope); + let host = unsafe { &mut *host_ptr }; + match owner { + native_bridge::OwnerDispatchScope::Top => { if host.browsing_context_is_closed() { return; } host.browsing_context_name().set(next.clone()); } + native_bridge::OwnerDispatchScope::Child(handle) => { + host.set_child_browsing_context_name(handle, next.clone()) + } + native_bridge::OwnerDispatchScope::LightweightPopup(popup_id) => { + host.set_lightweight_popup_window_name(popup_id, &next) + } } define_non_enumerable_string_property(scope, receiver, WINDOW_NAME_SLOT, &next); } diff --git a/moli-renderer-v8/src/context_bootstrap/window_receiver.rs b/moli-renderer-v8/src/context_bootstrap/window_receiver.rs index b619477b1e2..2676e3d6b37 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_receiver.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_receiver.rs @@ -1,9 +1,11 @@ -use super::shared::WINDOW_NAVIGATOR_SLOT; +use super::shared::{CHILD_BROWSING_CONTEXT_HANDLE_SLOT, WINDOW_NAVIGATOR_SLOT}; use crate::util::{ - callback_data_index_value, context_host_ptr_from_context_slot, - context_host_ptr_from_window_object, get_private_value, throw_type_error, + callback_data_index_value, context_host_ptr_from_context_slot, get_private_value, + throw_type_error, }; +pub(crate) const NATIVE_WINDOW_BRAND_SLOT: &str = "__moliNativeWindowBrand"; + /// Recognizes a native Window receiver for WebIDL brand checks. /// /// A retained, detached global proxy can lose both its creation context and @@ -34,7 +36,11 @@ fn is_live_window_receiver<'s>( scope: &mut v8::PinScope<'s, '_>, receiver: v8::Local<'s, v8::Object>, ) -> bool { - if context_host_ptr_from_window_object(scope, receiver).is_some() { + if get_private_value(scope, receiver, NATIVE_WINDOW_BRAND_SLOT).is_some() + || crate::native_bridge::lightweight_popup_id_from_window(scope, receiver).is_some() + || crate::native_bridge::cross_origin_lightweight_popup_id(scope, receiver).is_some() + || get_private_value(scope, receiver, CHILD_BROWSING_CONTEXT_HANDLE_SLOT).is_some() + { return true; } // The shared helper maps an `undefined` rollback marker to `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 9b08f02fbc7..e710c9aa40f 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs @@ -161,15 +161,38 @@ pub(crate) fn window_open_callback<'s>( rv.set(v8::null(scope).into()); return; }; + // Argument conversion and relative URL settings belong to the calling + // realm; native target selection belongs to the Window receiver. + let host = unsafe { &*host_ptr }; + let entered_base_url = entered_window_api_base_url(scope, host); + let owner = crate::native_bridge::marked_window_dispatch_scope(scope, args.this()) + .or_else(|| { + args.this() + .get_creation_context(scope) + .and_then(|context| { + host.window_execution_context_identity_for_access_check(context) + }) + .map(|identity| identity.dispatch_scope()) + }) + .unwrap_or_else(|| host.entered_owner_dispatch_scope(scope)); + let previous = owner.enter(scope); + window_open_for_receiver(scope, args, rv, parsed, host_ptr, entered_base_url); + owner.restore(scope, previous); +} + +fn window_open_for_receiver<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, + parsed: WindowOpenArgs, + host_ptr: *mut crate::native_bridge::JsContextHost, + entered_base_url: Url, +) { let entered_window = { let host = unsafe { &*host_ptr }; window_open_entered_window(scope, host).unwrap_or_else(|| args.this()) }; let special_target = SpecialBrowsingContextTarget::parse(&parsed.target_name); - let entered_base_url = { - let host = unsafe { &*host_ptr }; - entered_window_api_base_url(scope, host) - }; let url = if parsed.raw_url.is_empty() { Url::parse("about:blank").expect("about:blank should parse") } else { @@ -198,6 +221,31 @@ pub(crate) fn window_open_callback<'s>( } let parsed_features = WindowOpenFeatures::parse(&parsed.features); let suppress_opener = parsed_features.suppresses_opener(); + let named_source = if special_target.is_none() && !parsed.target_name.is_empty() { + let host = unsafe { &*host_ptr }; + match host.entered_owner_dispatch_scope(scope) { + OwnerDispatchScope::Top => host.browsing_context_name().get() == parsed.target_name, + OwnerDispatchScope::Child(handle) => { + host.child_browsing_context_handle_by_name(&parsed.target_name) == Some(handle) + } + OwnerDispatchScope::LightweightPopup(popup_id) => host + .lightweight_popup_window_name(popup_id) + .is_some_and(|name| name == parsed.target_name), + } + } else { + false + }; + if named_source { + if parsed.raw_url.is_empty() { + rv.set(entered_window.into()); + } else { + navigate_window_open_self(scope, entered_window, url.as_str(), &mut rv); + } + if suppress_opener { + rv.set_null(); + } + return; + } let mut creator_policy_container = { let host = unsafe { &*host_ptr }; window_open_entered_policy_container(scope, host) diff --git a/moli-renderer-v8/src/native_bridge/bindings.rs b/moli-renderer-v8/src/native_bridge/bindings.rs index 4bae92947ff..4f32a318846 100644 --- a/moli-renderer-v8/src/native_bridge/bindings.rs +++ b/moli-renderer-v8/src/native_bridge/bindings.rs @@ -242,6 +242,15 @@ impl NativeBridgeBindings { .new_instance(scope) .unwrap_or_else(|| panic!("failed to instantiate `{prototype_name}` wrapper")); if matches!(wrapper_kind, WrapperKind::Window) { + let key = v8::Private::for_api( + scope, + Some(crate::util::v8str( + scope, + crate::context_bootstrap::NATIVE_WINDOW_BRAND_SLOT, + )), + ); + let brand = v8::Boolean::new(scope, true); + let _ = wrapper.set_private(scope, key, brand.into()); // Window brand checks can cross realm boundaries, so the small // number of Window wrappers retain their object-local host marker. let host_external = v8::External::new(scope, host_ptr as *mut c_void); 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 ea72cde177a..163ee60f572 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 @@ -1367,9 +1367,7 @@ impl JsContextHost { (!is_cross_origin_window_proxy(scope, object)).then_some(object) } - pub(in crate::native_bridge::context_host) fn child_browsing_context_parent_top_for_realm_global< - 's, - >( + pub(in crate::native_bridge) fn child_browsing_context_parent_top_for_realm_global<'s>( &mut self, scope: &mut v8::PinScope<'s, '_>, handle: DomHandle, diff --git a/moli-renderer-v8/src/native_bridge/context_host/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/mod.rs index 4e304a95698..4f28d9330ed 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -227,7 +227,7 @@ pub(crate) use window_execution_context::{ DetachedWindowFetchContext, WindowEnvironmentSettings, WindowExecutionContextAccessPolicy, WindowExecutionContextBinding, WindowExecutionContextIdentity, WindowExecutionContextOwner, WindowFetchContext, WindowOperationReceiver, WindowOperationReceiverCaptureError, - WindowTaskTarget, + WindowTaskTarget, marked_window_dispatch_scope, }; use window_execution_context::{ WindowExecutionContextRealmRecords, WindowExecutionContextRealmRegistration, 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 7d98f934cac..f3671857fbd 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs @@ -643,7 +643,13 @@ impl JsContextHost { true } - fn set_lightweight_popup_window_name(&mut self, popup_id: u64, next: &str) { + pub(crate) fn lightweight_popup_window_name(&self, popup_id: u64) -> Option { + self.lightweight_popup_record(popup_id) + .filter(|record| record.is_open()) + .map(|record| record.name.get()) + } + + pub(crate) fn set_lightweight_popup_window_name(&mut self, popup_id: u64, next: &str) { let Some(record) = self .lightweight_popup_record(popup_id) .filter(|record| record.is_open()) @@ -684,8 +690,7 @@ impl JsContextHost { creator_policy_container: DocumentPolicyContainer, update_existing_opener: bool, ) -> Option> { - if opener.is_some() - && let Some(name) = trackable_lightweight_popup_window_name(target_name) + if let Some(name) = trackable_lightweight_popup_window_name(target_name) && let Some(popup_id) = self.lightweight_popup_browsing_contexts .iter() @@ -773,14 +778,15 @@ impl JsContextHost { let initial_referrer = creator_policy_container.document_referrer.clone(); let opener_endpoint = lightweight_popup_initiator_endpoint(scope, opener, opener_child_handle); + let creator_scope = opener_endpoint + .map(|endpoint| endpoint.dispatch_scope()) + .unwrap_or_else(|| self.entered_owner_dispatch_scope(scope)); let creator_resource_authority = self - .document_resource_loader_for_dispatch_scope(self.entered_owner_dispatch_scope(scope)) + .document_resource_loader_for_dispatch_scope(creator_scope) .expect("new popup Document requires its creator's exact resource authority") .clone(); let storage_scope = self.lightweight_popup_storage_scope_for_initiated_navigation( - scope, - opener, - opener_child_handle, + creator_scope, &initial_url, opener_sandbox_policy.is_some_and(|policy| policy.forces_opaque_origin), ); @@ -887,9 +893,7 @@ impl JsContextHost { initial_execution_context_owner, ) } else if moli_url::is_about_blank(&initial_url) - && let Some(inherited) = opener_endpoint.and_then(|endpoint| { - self.window_access_origin_for_dispatch_scope(endpoint.dispatch_scope()) - }) + && let Some(inherited) = self.window_access_origin_for_dispatch_scope(creator_scope) { inherited } else if storage_scope.origin() == "null" { @@ -1052,8 +1056,10 @@ impl JsContextHost { &mut navigation_state.policy_container.sandbox, opener_sandbox_policy, ); - let initiator_endpoint = - lightweight_popup_initiator_endpoint(scope, opener, opener_child_handle); + let creator_scope = + lightweight_popup_initiator_endpoint(scope, opener, opener_child_handle) + .map(|endpoint| endpoint.dispatch_scope()) + .unwrap_or_else(|| self.entered_owner_dispatch_scope(scope)); let previous_url = lightweight_popup_location_href(scope, window).unwrap_or_else(about_blank_url); let target_url = parsed_url; @@ -1083,9 +1089,7 @@ impl JsContextHost { ); let queue_synthetic_load = if moli_url::is_about_blank(&target_url) { let storage_scope = self.lightweight_popup_storage_scope_for_initiated_navigation( - scope, - opener, - opener_child_handle, + creator_scope, &target_url, opener_sandbox_policy.is_some_and(|policy| policy.forces_opaque_origin), ); @@ -1102,9 +1106,9 @@ impl JsContextHost { LightweightPopupDocumentCommitOrigin::FromNavigationResponse( "null".to_owned(), ) - } else if let Some(inherited) = initiator_endpoint.and_then(|endpoint| { - self.window_access_origin_for_dispatch_scope(endpoint.dispatch_scope()) - }) { + } else if let Some(inherited) = + self.window_access_origin_for_dispatch_scope(creator_scope) + { LightweightPopupDocumentCommitOrigin::InheritExact(inherited) } else { LightweightPopupDocumentCommitOrigin::FromNavigationResponse( @@ -1268,11 +1272,9 @@ impl JsContextHost { target_store } - fn lightweight_popup_storage_scope_for_initiated_navigation<'s>( + fn lightweight_popup_storage_scope_for_initiated_navigation( &mut self, - scope: &mut v8::PinScope<'s, '_>, - opener: Option>, - opener_child_handle: Option, + creator: OwnerDispatchScope, target_url: &Url, sandbox_forces_opaque_origin: bool, ) -> LightweightPopupStorageScope { @@ -1280,13 +1282,12 @@ impl JsContextHost { return self.lightweight_popup_opaque_storage_scope(target_url); } if moli_url::is_about_blank(target_url) - && let Some(opener_scope) = - self.lightweight_popup_opener_storage_scope(scope, opener, opener_child_handle) + && let Some(opener_scope) = self.lightweight_popup_storage_scope_for_owner(creator) { if opener_scope.origin() == "null" { return opener_scope; } - if opener_child_handle.is_none() { + if !matches!(creator, OwnerDispatchScope::Child(_)) { return opener_scope; } let storage_key = web_storage_key_for_child_about_blank_popup(&opener_scope); @@ -1346,22 +1347,27 @@ impl JsContextHost { opener: Option>, opener_child_handle: Option, ) -> Option { - if let Some(opener) = opener - && let Some(popup_id) = lightweight_popup_id_from_window(scope, opener) - { - return self - .lightweight_popup_bound_web_storage_scope(popup_id) - .map(LightweightPopupStorageScope::from_web_storage_scope); - } - if let Some(handle) = opener_child_handle { - let top_origin = origin_ascii_serialization(self.document_url()); - return self - .child_browsing_context_web_storage_scope(handle, &top_origin) - .map(LightweightPopupStorageScope::from_web_storage_scope); - } - Some(LightweightPopupStorageScope::from_web_storage_scope( - self.top_web_storage_scope(), - )) + let owner = lightweight_popup_initiator_endpoint(scope, opener, opener_child_handle) + .map(|endpoint| endpoint.dispatch_scope()) + .unwrap_or(OwnerDispatchScope::Top); + self.lightweight_popup_storage_scope_for_owner(owner) + } + + fn lightweight_popup_storage_scope_for_owner( + &mut self, + owner: OwnerDispatchScope, + ) -> Option { + let storage_scope = match owner { + OwnerDispatchScope::Top => Some(self.top_web_storage_scope()), + OwnerDispatchScope::Child(handle) => { + let top_origin = origin_ascii_serialization(self.document_url()); + self.child_browsing_context_web_storage_scope(handle, &top_origin) + } + OwnerDispatchScope::LightweightPopup(popup_id) => { + self.lightweight_popup_bound_web_storage_scope(popup_id) + } + }; + storage_scope.map(LightweightPopupStorageScope::from_web_storage_scope) } pub(crate) fn active_lightweight_popup_base_url( @@ -4447,10 +4453,9 @@ fn lightweight_popup_window_name_getter<'s>( rv.set(v8::String::empty(scope).into()); if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) && let Some(popup_id) = lightweight_popup_id_from_window(scope, window) - && let Some(record) = unsafe { &*host_ptr }.lightweight_popup_record(popup_id) - && record.is_open() + && let Some(name) = unsafe { &*host_ptr }.lightweight_popup_window_name(popup_id) { - rv.set(v8::String::new(scope, &record.name.get()).unwrap().into()); + rv.set(v8::String::new(scope, &name).unwrap().into()); } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/popups/renderer_owned.rs b/moli-renderer-v8/src/native_bridge/context_host/popups/renderer_owned.rs index 5bdc4037989..0bbda1983af 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups/renderer_owned.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups/renderer_owned.rs @@ -49,10 +49,12 @@ impl JsContextHost { .sandbox .with_response_content_security_policy(sandbox); } + let creator_scope = + lightweight_popup_initiator_endpoint(scope, Some(creator), creator_child_handle) + .expect("popup navigation requires its creator Window") + .dispatch_scope(); let storage_scope = self.lightweight_popup_storage_scope_for_initiated_navigation( - scope, - Some(creator), - creator_child_handle, + creator_scope, &about_blank_url(), policy.sandbox.forces_opaque_origin, ); @@ -100,12 +102,11 @@ impl JsContextHost { let environment = self .page_script_environment() .ok_or_else(|| anyhow::anyhow!("auxiliary creator Page is retired"))?; - if exposes_opener - && let Some(name) = trackable_lightweight_popup_window_name(target_name) + if let Some(name) = trackable_lightweight_popup_window_name(target_name) && let Some(existing) = environment.named_related_window(&name, self.browsing_context_name()) { - if update_existing_opener { + if update_existing_opener && exposes_opener { existing.set_opener(Some(v8::Global::new(scope, creator))); } let (window, name) = existing @@ -176,10 +177,12 @@ impl JsContextHost { policy.sandbox = DocumentSandboxPolicy::default(); } inherit_lightweight_popup_opener_sandbox(&mut policy.sandbox, sandbox); + let creator_scope = + lightweight_popup_initiator_endpoint(scope, Some(creator), creator_child_handle) + .expect("popup navigation requires its creator Window") + .dispatch_scope(); let storage_scope = self.lightweight_popup_storage_scope_for_initiated_navigation( - scope, - Some(creator), - creator_child_handle, + creator_scope, &initial_url, policy.sandbox.forces_opaque_origin, ); diff --git a/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs b/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs index 5b94a742b4d..41c038ccc7f 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs @@ -524,6 +524,11 @@ impl JsContextHost { if let Some(popup_id) = active_lightweight_popup_id(scope) { return OwnerDispatchScope::LightweightPopup(popup_id); } + if let Some(identity) = + self.window_execution_context_identity_for_access_check(scope.get_current_context()) + { + return identity.dispatch_scope(); + } OwnerDispatchScope::Top } diff --git a/moli-renderer-v8/src/native_bridge/context_host/window_execution_context.rs b/moli-renderer-v8/src/native_bridge/context_host/window_execution_context.rs index db36724c1e1..98850380735 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/window_execution_context.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/window_execution_context.rs @@ -21,7 +21,9 @@ mod settings; pub(crate) use binding::WindowExecutionContextBinding; pub(crate) use fetch::{DetachedWindowFetchContext, WindowFetchContext, WindowTaskTarget}; -pub(crate) use operation_receiver::{WindowOperationReceiver, WindowOperationReceiverCaptureError}; +pub(crate) use operation_receiver::{ + WindowOperationReceiver, WindowOperationReceiverCaptureError, marked_window_dispatch_scope, +}; pub(crate) use registry::{ WindowExecutionContextAccessPolicy, WindowExecutionContextIdentity, WindowExecutionContextOwner, }; diff --git a/moli-renderer-v8/src/native_bridge/context_host/window_execution_context/operation_receiver.rs b/moli-renderer-v8/src/native_bridge/context_host/window_execution_context/operation_receiver.rs index dc52daa5cb6..1d893c5f4c9 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/window_execution_context/operation_receiver.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/window_execution_context/operation_receiver.rs @@ -119,7 +119,7 @@ impl WindowOperationReceiver { } } -fn marked_window_dispatch_scope( +pub(crate) fn marked_window_dispatch_scope( scope: &mut v8::PinScope<'_, '_>, receiver: v8::Local<'_, v8::Object>, ) -> Option { diff --git a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs index b6ecf3cc0e2..1bbc01947dd 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs @@ -476,23 +476,30 @@ fn browsing_context_dispatch_scope_for_node( .map(crate::native_bridge::OwnerDispatchScope::Child) } -fn navigate_special_target_from_window<'s>( +fn navigate_special_target_for_dispatch_scope<'s>( scope: &mut v8::PinScope<'s, '_>, runtime_ptr: *mut JsContextHost, - source_window: v8::Local<'s, v8::Object>, + dispatch_scope: crate::native_bridge::OwnerDispatchScope, target: Option, resolved_url: &str, ) -> Option> { + use crate::native_bridge::OwnerDispatchScope; let global = scope.get_current_context().global(scope); - let target_window = match target { - None | Some(SpecialBrowsingContextTarget::Current) => source_window, - Some(SpecialBrowsingContextTarget::Top) => source_window - .get(scope, v8str(scope, "top").into()) - .and_then(|value| v8::Local::::try_from(value).ok())?, - Some(SpecialBrowsingContextTarget::Parent) => source_window - .get(scope, v8str(scope, "parent").into()) - .and_then(|value| v8::Local::::try_from(value).ok())?, - Some(SpecialBrowsingContextTarget::Blank) => return None, + let target_window = match (dispatch_scope, target) { + (_, Some(SpecialBrowsingContextTarget::Blank)) => return None, + ( + OwnerDispatchScope::Child(handle), + Some(SpecialBrowsingContextTarget::Parent | SpecialBrowsingContextTarget::Top), + ) => { + let (parent, top) = unsafe { &mut *runtime_ptr } + .child_browsing_context_parent_top_for_realm_global(scope, handle, global); + if target == Some(SpecialBrowsingContextTarget::Parent) { + parent + } else { + top + } + } + _ => browsing_context_window_for_dispatch_scope(scope, runtime_ptr, dispatch_scope)?, }; let navigated = if resolved_url.is_empty() { true @@ -516,12 +523,10 @@ pub(crate) fn navigate_existing_browsing_context_target<'s>( "a new-context target cannot use existing-context navigation" ); let dispatch_scope = unsafe { &*runtime_ptr }.entered_owner_dispatch_scope(scope); - let source_window = - browsing_context_window_for_dispatch_scope(scope, runtime_ptr, dispatch_scope)?; - navigate_special_target_from_window( + navigate_special_target_for_dispatch_scope( scope, runtime_ptr, - source_window, + dispatch_scope, Some(target), resolved_url, ) @@ -542,16 +547,11 @@ pub(super) fn navigate_hyperlink_source_browsing_context( crate::native_bridge::OwnerDispatchScope::Top => false, crate::native_bridge::OwnerDispatchScope::Child(handle) => unsafe { &mut *runtime_ptr } .navigate_child_browsing_context_to_url(scope, handle, resolved_url), - crate::native_bridge::OwnerDispatchScope::LightweightPopup(popup_id) => { - let Some(source_window) = - unsafe { &*runtime_ptr }.lightweight_popup_window(scope, popup_id) - else { - return false; - }; - navigate_special_target_from_window( + crate::native_bridge::OwnerDispatchScope::LightweightPopup(_) => { + navigate_special_target_for_dispatch_scope( scope, runtime_ptr, - source_window, + dispatch_scope, Some(SpecialBrowsingContextTarget::Current), resolved_url, ) @@ -586,15 +586,10 @@ pub(crate) fn navigate_target_browsing_context<'s>( } None => { let dispatch_scope = unsafe { &*runtime_ptr }.entered_owner_dispatch_scope(scope); - let Some(source_window) = - browsing_context_window_for_dispatch_scope(scope, runtime_ptr, dispatch_scope) - else { - return false; - }; - navigate_special_target_from_window( + navigate_special_target_for_dispatch_scope( scope, runtime_ptr, - source_window, + dispatch_scope, None, resolved_url, ) @@ -680,15 +675,10 @@ pub(in crate::native_bridge) fn navigate_hyperlink_target_browsing_context<'s>( else { return false; }; - let Some(source_window) = - browsing_context_window_for_dispatch_scope(scope, runtime_ptr, dispatch_scope) - else { - return false; - }; - navigate_special_target_from_window( + navigate_special_target_for_dispatch_scope( scope, runtime_ptr, - source_window, + dispatch_scope, special_target, resolved_url, ) diff --git a/moli-renderer-v8/src/native_bridge/mod.rs b/moli-renderer-v8/src/native_bridge/mod.rs index 7e677c128d6..211ffe61574 100644 --- a/moli-renderer-v8/src/native_bridge/mod.rs +++ b/moli-renderer-v8/src/native_bridge/mod.rs @@ -8,7 +8,7 @@ mod context_host; mod current_input; pub(crate) use context_host::{ JsContextHost, JsContextHostPageTaskCapabilities, PendingScrollObservableEffects, - PostParseAutofocusAdmission, ServiceWorkerWindowOwner, + PostParseAutofocusAdmission, ServiceWorkerWindowOwner, marked_window_dispatch_scope, }; pub(crate) use current_input::{ CurrentInputEvent, CurrentInputEventScope, InputNavigationPolicy, navigation_policy_from_event, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage.rs index 74eaa1dcbf0..bbf750d2b63 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage.rs @@ -314,12 +314,14 @@ async fn spawn_about_blank_popup_storage_child_server() -> (String, JoinHandle "#; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage/window_security_and_domain.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage/window_security_and_domain.rs index 5049388dcfb..058de7fbcdb 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage/window_security_and_domain.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage/window_security_and_domain.rs @@ -1,5 +1,64 @@ use super::*; +#[tokio::test] +async fn cross_origin_window_open_special_targets_preserve_allowed_proxy_surface() { + for nested in [false, true] { + let leaf = r#""# + .to_owned(); + let mut bodies = Vec::new(); + if nested { + bodies.push( + r#""# + .to_owned(), + ); + } + bodies.push(leaf); + let server = StaticHttpServer::spawn_with_bodies(bodies).await; + let loader = static_http_loader([]); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://different-origin.test/parent", + &loader, + ); + vm.eval(&format!( + r#" +globalThis.results = []; +addEventListener('message', event => results.push(event.data)); +const frame = document.createElement('iframe'); +frame.src = {:?}; +(document.body || document.documentElement).append(frame); +"#, + server.base_url().join("/child").unwrap().as_str() + )) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(results.length)", + "2", + "special target messages", + ) + .await; + assert_eq!( + vm.eval("JSON.stringify(results.sort((a, b) => a.target.localeCompare(b.target)))") + .unwrap(), + if nested { + r#"[{"target":"_parent","denied":false,"same":true,"isTop":false},{"target":"_top","denied":true,"same":false,"isTop":true}]"# + } else { + r#"[{"target":"_parent","denied":true,"same":true,"isTop":true},{"target":"_top","denied":true,"same":true,"isTop":true}]"# + } + ); + } +} + #[tokio::test] async fn same_origin_nested_children_share_window_security_token_without_top_access() { let (root_url, server) = spawn_nested_same_origin_window_access_server().await; @@ -1537,8 +1596,21 @@ async fn third_party_about_blank_popup_does_not_reuse_opener_or_first_party_stor assert_eq!( vm.eval("globalThis.__aboutBlankPopupStorageMessage || 'missing'") .expect("about:blank popup message should evaluate"), - r#"{"popupBefore":null,"popupAfter":"popup-first-party","childAfter":"child-partition","opener":true}"# - ); + r#"{"popupBefore":null,"popupAfter":"popup-first-party","childAfter":"child-partition","opener":true,"isolatedReturn":true}"# + ); + { + let host = vm._context_host.borrow(); + let ordinary = host + .lightweight_popup_initial_empty_document_storage_key(1) + .expect("ordinary popup storage identity"); + let isolated = host + .lightweight_popup_initial_empty_document_storage_key(2) + .expect("noopener popup storage identity"); + assert_eq!( + ordinary, isolated, + "creator storage identity must survive noopener" + ); + } assert_eq!( vm.eval("localStorage.getItem('popup-scope')") .expect("top localStorage should evaluate"), diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/popup_window.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/popup_window.rs index af6f17cb37b..65167cdf01b 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/popup_window.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/popup_window.rs @@ -658,6 +658,277 @@ fn window_open_named_lightweight_popup_reuses_without_recloning_session_storage( r#"{"sameWindow":true,"popupName":"sessionStorageTestWindow","initial":null,"openerFoo":"BAR","popupFoo":null}"# ); } + +#[test] +fn window_open_does_not_reuse_named_noopener_popup_from_an_isolated_group() { + let mut vm = new_storage_test_vm("https://example.com/"); + + assert_eq!( + vm.eval("open('about:blank#isolated', 'report', 'noopener') === null") + .expect("isolated named popup should evaluate"), + "true" + ); + let first = vm.take_pending_popup_activations(); + assert_eq!(first.len(), 1); + let first_popup_id = first[0].popup_id().expect("lightweight popup id"); + + assert_eq!( + vm.eval( + "(() => { const popup = open('about:blank#related', 'report'); return [popup.name, popup.location.href].join('|'); })()" + ) + .expect("related named popup should evaluate"), + "report|about:blank#related" + ); + let second = vm.take_pending_popup_activations(); + assert_eq!(second.len(), 1); + assert_ne!( + second[0].popup_id(), + Some(first_popup_id), + "an isolated noopener popup is not a reusable named target for its creator" + ); +} + +#[test] +fn window_open_noopener_reuses_a_related_named_popup_but_returns_null() { + let mut vm = new_storage_test_vm("https://example.com/"); + + let first = vm + .eval("globalThis.popup = open('about:blank#related', 'report'); popup.location.href") + .expect("related named popup should evaluate"); + assert_eq!(first, "about:blank#related"); + let first_activation = vm.take_pending_popup_activations(); + let first_popup_id = first_activation[0].popup_id().expect("first popup id"); + + assert_eq!( + vm.eval("open('about:blank#isolated', 'report', 'noopener') === null") + .expect("isolated named popup should evaluate"), + "true" + ); + let second_activation = vm.take_pending_popup_activations(); + assert_eq!(second_activation.len(), 1); + assert_eq!(second_activation[0].popup_id(), Some(first_popup_id)); + assert_eq!( + vm.eval("JSON.stringify([popup.location.href, popup.opener === window])") + .expect("reused popup state should evaluate"), + r#"["about:blank#isolated",true]"# + ); +} + +#[test] +fn named_popup_group_survives_intermediate_opener_close() { + let mut vm = new_storage_test_vm("https://example.com/"); + + assert_eq!( + vm.eval( + "(() => { const parent = open('', 'parent'); const child = parent.open('', 'child'); parent.close(); return open('', 'child') === child; })()" + ) + .expect("popup group should survive opener close"), + "true" + ); +} + +#[test] +fn window_open_empty_url_selects_named_source_without_navigation() { + let mut vm = new_storage_test_vm("https://example.com/source"); + assert_eq!( + vm.eval( + "window.name = 'self'; globalThis.marker = 7; const selected = open('', 'self'); JSON.stringify([selected === window, location.href, marker]);" + ) + .expect("empty URL named-source selection should evaluate"), + r#"[true,"https://example.com/source",7]"# + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn window_open_empty_parent_uses_internal_identity_without_running_public_lookup() { + let mut vm = new_storage_test_vm("https://example.com/source"); + assert_eq!( + vm.eval( + r#" +const fake = { fake: true }; +let parentReads = 0; +Object.defineProperty(window, "parent", { + configurable: true, + get() { ++parentReads; return fake; } +}); +globalThis.marker = 9; +const selected = open("", "_parent"); +JSON.stringify([selected === window, selected === fake, location.href, marker, parentReads]); +"#, + ) + .expect("empty URL parent selection should evaluate"), + r#"[true,false,"https://example.com/source",9,0]"# + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn borrowed_popup_open_parent_and_top_use_the_popup_receiver_identity() { + let mut vm = new_storage_test_vm("https://example.com/source"); + assert_eq!( + vm.eval( + r#" +const popup = open("about:blank#popup", "report"); +const selectedParent = popup.open("", "_parent"); +const selectedTop = popup.open("", "_top"); +JSON.stringify([ + selectedParent === popup, + selectedTop === popup, + selectedParent === window, + selectedTop === window, + popup.location.href +]); +"#, + ) + .expect("borrowed popup special-target selection should evaluate"), + r#"[true,true,false,false,"about:blank#popup"]"# + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[tokio::test] +async fn child_window_open_empty_parent_and_top_use_internal_browsing_context_identity() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); + let mut vm = + new_storage_page_task_executor_test_vm_with_loader("https://example.com/source", &loader); + vm.eval( + r#" +globalThis.frame = document.createElement("iframe"); +frame.srcdoc = "

child

"; +(document.body || document.documentElement || document).appendChild(frame); +"#, + ) + .expect("child frame setup should evaluate"); + vm.drain_ready_page_task_executor_turns_for_setup(&loader, 128) + .await + .expect("child frame should commit"); + + assert_eq!( + vm.eval( + r#" +frame.contentWindow.__expectedParent = window; +frame.contentWindow.eval(` + const realParent = window.__expectedParent; + const realTop = window.__expectedParent; + const fake = {}; + let publicParentReads = 0; + let internalParentReads = 0; + let internalTopReads = 0; + Object.defineProperty(window, "parent", { + configurable: true, + get() { ++publicParentReads; return fake; } + }); + Object.defineProperty(window, "__moliWindowParent", { + configurable: true, + get() { ++internalParentReads; return fake; } + }); + Object.defineProperty(window, "__moliWindowTop", { + configurable: true, + get() { ++internalTopReads; return fake; } + }); + const selectedParent = open("", "_parent"); + const selectedTop = open("", "_top"); + JSON.stringify([ + selectedParent === realParent, + selectedTop === realTop, + selectedParent === fake, + selectedTop === fake, + publicParentReads, + internalParentReads, + internalTopReads, + location.href + ]); +`) +"#, + ) + .expect("child special-target selection should evaluate"), + r#"[true,true,false,false,0,0,0,"about:srcdoc"]"# + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn window_open_empty_url_reuses_named_popup_without_navigation() { + let mut vm = new_storage_test_vm("https://example.com/source"); + assert_eq!( + vm.eval( + "globalThis.popup = open('about:blank#kept', 'report'); const selected = open('', 'report'); JSON.stringify([selected === popup, popup.location.href]);" + ) + .expect("empty URL named-popup selection should evaluate"), + r#"[true,"about:blank#kept"]"# + ); + let activations = vm.take_pending_popup_activations(); + assert_eq!(activations.len(), 2); + assert_eq!(activations[0].popup_id(), activations[1].popup_id()); +} + +#[test] +fn window_open_prefers_the_same_named_source_over_a_related_popup() { + let mut vm = new_storage_test_vm("https://example.com/source"); + + assert_eq!( + vm.eval( + r#" +(() => { + const popup = open("about:blank#popup", "shared"); + window.name = "shared"; + const selected = open("about:blank#self", "shared"); + return JSON.stringify({ + selectedSelf: selected === window, + popupHref: popup.location.href, + popupName: popup.name + }); +})() +"#, + ) + .expect("same-named source selection should evaluate"), + r#"{"selectedSelf":true,"popupHref":"about:blank#popup","popupName":"shared"}"# + ); + let activations = vm.take_pending_popup_activations(); + assert_eq!( + activations.len(), + 1, + "the second open must navigate the source instead of reopening the popup" + ); + let navigation = vm + .take_pending_location_navigation_with_seed() + .expect("same-named source navigation"); + assert_eq!(navigation.url.as_str(), "about:blank#self"); +} + +#[test] +fn window_open_noopener_prefers_the_same_named_source_and_returns_null() { + let mut vm = new_storage_test_vm("https://example.com/source"); + + assert_eq!( + vm.eval( + r#" +const popup = open("about:blank#popup", "shared"); +window.name = "shared"; +open("about:blank#self", "shared", "noopener") === null; +"#, + ) + .expect("noopener same-named source selection should evaluate"), + "true" + ); + let activations = vm.take_pending_popup_activations(); + assert_eq!( + activations.len(), + 1, + "the noopener call must navigate the source instead of reopening the popup" + ); + assert_eq!( + vm.eval("popup.location.href") + .expect("existing popup URL should evaluate"), + "about:blank#popup" + ); + let navigation = vm + .take_pending_location_navigation_with_seed() + .expect("noopener same-named source navigation"); + assert_eq!(navigation.url.as_str(), "about:blank#self"); +} + #[tokio::test] async fn window_open_named_lightweight_popup_reuse_pushes_history_and_back_traverses() { let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/window_surface.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/window_surface.rs index 5acb350809d..10931ee775d 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/window_surface.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/window_surface.rs @@ -833,3 +833,111 @@ fn window_dialog_and_open_arguments_use_webidl_conversion() { "undefined|TypeError|false|RangeError|null|TypeError|[object Window]|TypeError|TypeError|RangeError" ); } +#[test] +fn window_name_accessors_reject_non_window_receivers_without_mutating_the_window() { + let mut vm = new_storage_test_vm("https://example.com/"); + + let result = vm + .eval( + r#" + (() => { + const descriptor = Object.getOwnPropertyDescriptor(window, "name"); + const outcome = callback => { + try { callback(); return "ok"; } + catch (error) { return error.name; } + }; + const before = window.name; + return JSON.stringify({ + setter: outcome(() => descriptor.set.call({}, "forged")), + getter: outcome(() => descriptor.get.call({})), + before, + after: window.name + }); + })() + "#, + ) + .expect("window.name illegal receiver probe should evaluate"); + + assert_eq!( + result, + r#"{"setter":"TypeError","getter":"TypeError","before":"","after":""}"# + ); +} + +#[test] +fn window_name_accessors_follow_the_receiver_realm_and_preserve_state_on_conversion_errors() { + let mut vm = new_storage_test_vm("https://example.com/"); + + let result = vm + .eval( + r#" + (() => { + const frame = document.createElement("iframe"); + (document.body || document.documentElement || document).appendChild(frame); + const child = frame.contentWindow; + const parentDescriptor = Object.getOwnPropertyDescriptor(window, "name"); + const childDescriptor = Object.getOwnPropertyDescriptor(child, "name"); + + parentDescriptor.set.call(child, "child-from-parent-realm"); + childDescriptor.set.call(window, "parent-from-child-realm"); + const borrowedParent = childDescriptor.get.call(window); + const borrowedChild = parentDescriptor.get.call(child); + + const popup = window.open("about:blank", "popup-owner"); + const borrowedPopupBefore = parentDescriptor.get.call(popup); + parentDescriptor.set.call(popup, "renamed-popup-owner"); + const borrowedPopupAfter = parentDescriptor.get.call(popup); + const openerAfterPopupRename = window.name; + + const iterator = document.createNodeIterator(document); + let invalidReceiverGetter; + let invalidReceiverSetter; + let invalidReceiverConverted = false; + try { + parentDescriptor.get.call(iterator); + } catch (error) { + invalidReceiverGetter = error.name; + } + try { + parentDescriptor.set.call(iterator, { + toString() { + invalidReceiverConverted = true; + return "forged"; + } + }); + } catch (error) { + invalidReceiverSetter = error.name; + } + + let conversionError; + try { + childDescriptor.set.call(window, { + toString() { throw new RangeError("window-name-conversion"); } + }); + } catch (error) { + conversionError = `${error.name}:${error.message}`; + } + + return JSON.stringify({ + borrowedParent, + borrowedChild, + borrowedPopupBefore, + borrowedPopupAfter, + openerAfterPopupRename, + invalidReceiverGetter, + invalidReceiverSetter, + invalidReceiverConverted, + parentAfterError: window.name, + childAfterError: child.name, + conversionError + }); + })() + "#, + ) + .expect("borrowed Window.name accessors should preserve their receiver realm"); + + assert_eq!( + result, + r#"{"borrowedParent":"parent-from-child-realm","borrowedChild":"child-from-parent-realm","borrowedPopupBefore":"popup-owner","borrowedPopupAfter":"renamed-popup-owner","openerAfterPopupRename":"parent-from-child-realm","invalidReceiverGetter":"TypeError","invalidReceiverSetter":"TypeError","invalidReceiverConverted":false,"parentAfterError":"parent-from-child-realm","childAfterError":"child-from-parent-realm","conversionError":"RangeError:window-name-conversion"}"# + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/element_navigation.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/element_navigation.rs index 1cf3e780679..4feffb0a72f 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/element_navigation.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation/element_navigation.rs @@ -1,5 +1,45 @@ use super::*; +#[tokio::test] +async fn noopener_named_hyperlink_reuses_an_existing_iframe() { + let loader = static_http_loader([]); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://example.com/page.html", + &loader, + ); + vm.eval( + r#" +const body = document.body; +const frame = document.createElement('iframe'); +frame.name = 'report'; +body.append(frame); +const link = document.createElement('a'); +link.target = 'report'; +link.rel = 'noopener'; +link.href = 'about:blank#isolated'; +body.append(link); +link.click(); +"#, + ) + .expect("noopener named hyperlink should evaluate"); + + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "frame.contentWindow.location.href", + "about:blank#isolated", + "named iframe navigation", + ) + .await; + + assert_eq!( + vm.eval("frame.contentWindow.location.href") + .expect("iframe URL should evaluate"), + "about:blank#isolated" + ); + assert!(vm.take_pending_popup_activations().is_empty()); +} + #[tokio::test] async fn named_element_navigation_prefers_its_source_frame_over_duplicate_names() { for action in ["anchor", "submit", "requestSubmit"] {