From 4cd690931efc941fbe7320f401ae1ef79b4eb998 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sat, 29 Aug 2026 22:41:50 +0800 Subject: [PATCH] fix(renderer): execute javascript URL navigations internally --- moli-renderer-v8/src/context_bootstrap.rs | 10 +- .../src/context_bootstrap/javascript_url.rs | 67 +++ .../src/context_bootstrap/trusted_types.rs | 53 +++ .../src/document_runtime/security_policy.rs | 86 +++- .../src/native_bridge/context_host/popups.rs | 303 +++++++++---- .../context_host/security_policy.rs | 403 +++++++++--------- moli-renderer-v8/src/runtime/owner.rs | 79 ++-- .../src/runtime/owner/lifecycle_decision.rs | 2 +- .../src/runtime/owner_local_store/entry.rs | 79 ++-- .../src/runtime/owner_local_store/mod.rs | 2 +- .../src/runtime/owner_local_store/tests.rs | 28 +- .../runtime/page_vm/followed_navigation.rs | 13 +- .../src/runtime/page_vm/tests/lifecycle.rs | 259 ++++++++++- moli-renderer-v8/src/runtime/tests.rs | 31 +- moli-renderer-v8/src/script_vm.rs | 5 - moli-renderer-v8/src/script_vm/eval_exec.rs | 61 ++- .../src/script_vm/frame_script_jobs.rs | 40 +- .../src/script_vm/security_policy.rs | 3 + .../src/script_vm/tests/browser_api/misc.rs | 197 +++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 71 +++ 20 files changed, 1380 insertions(+), 412 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/javascript_url.rs diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 78e31ca250..f349d184b1 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -22,6 +22,7 @@ mod history_mutation; mod history_runtime; mod idle_detection; mod image_data; +mod javascript_url; pub(crate) use self::idle_detection::apply_idle_override_to_current_context; mod indexed_db; #[cfg(test)] @@ -285,6 +286,10 @@ pub(crate) use self::indexed_db::{ pub(in crate::context_bootstrap) use self::indexed_db::{ indexed_db_usage_bytes_for_storage_key, scoped_storage_bucket_indexed_db_factory, }; +pub(crate) use self::javascript_url::{ + arm_internal_javascript_url_eval, consume_internal_javascript_url_eval, + restore_internal_javascript_url_eval, +}; pub(crate) use self::location_runtime::sync_global_location_runtime_state; pub(crate) use self::location_runtime::{ sync_document_location_runtime_state_from_window, @@ -422,8 +427,9 @@ pub(crate) use self::streams::{ #[cfg(test)] pub(crate) use self::trusted_types::trusted_types_lazy_state_materialized; pub(crate) use self::trusted_types::{ - TrustedTypesCodeGenerationCheck, install_trusted_types_eval_runtime_state, - install_trusted_types_runtime_state, trusted_html_string_or_throw, trusted_html_value_string, + TrustedTypesCodeGenerationCheck, check_javascript_url_trusted_types, + install_trusted_types_eval_runtime_state, install_trusted_types_runtime_state, + trusted_html_string_or_throw, trusted_html_value_string, trusted_script_string_for_script_element_execution, trusted_script_string_or_type_error, trusted_script_url_string_or_throw, trusted_types_code_generation_check, trusted_types_code_generation_check_callback, diff --git a/moli-renderer-v8/src/context_bootstrap/javascript_url.rs b/moli-renderer-v8/src/context_bootstrap/javascript_url.rs new file mode 100644 index 0000000000..5b15ce56b4 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/javascript_url.rs @@ -0,0 +1,67 @@ +use crate::util::{get_private_value, set_private_value}; + +const INTERNAL_JAVASCRIPT_URL_EVAL_PERMIT_SLOT: &str = "__moliInternalJavascriptUrlEvalPermit"; + +/// Scoped permission for the direct eval used by the lightweight-popup +/// javascript-URL adapter. +/// +/// Lightweight popups do not own a V8 Context, so their adapter uses direct +/// eval to recover Script completion semantics while resolving globals +/// through the popup Window object. The source has already passed the target +/// Document's CSP and Trusted Types checks. The isolate callback consumes the +/// private marker on the first compilation; nested page-authored eval calls +/// therefore go through the normal policy path. +pub(crate) struct InternalJavascriptUrlEvalPermit { + previous: Option>, +} + +pub(crate) fn arm_internal_javascript_url_eval( + scope: &mut v8::PinScope<'_, '_>, +) -> InternalJavascriptUrlEvalPermit { + let global = scope.get_current_context().global(scope); + let previous = get_private_value(scope, global, INTERNAL_JAVASCRIPT_URL_EVAL_PERMIT_SLOT) + .map(|value| v8::Global::new(scope, value)); + let marker = v8::Object::new(scope); + set_private_value( + scope, + global, + INTERNAL_JAVASCRIPT_URL_EVAL_PERMIT_SLOT, + marker.into(), + ); + InternalJavascriptUrlEvalPermit { previous } +} + +pub(crate) fn restore_internal_javascript_url_eval( + scope: &mut v8::PinScope<'_, '_>, + permit: InternalJavascriptUrlEvalPermit, +) { + let global = scope.get_current_context().global(scope); + let previous = permit + .previous + .as_ref() + .map(|value| v8::Local::new(scope, value)) + .unwrap_or_else(|| v8::undefined(scope).into()); + set_private_value( + scope, + global, + INTERNAL_JAVASCRIPT_URL_EVAL_PERMIT_SLOT, + previous, + ); +} + +pub(crate) fn consume_internal_javascript_url_eval(scope: &mut v8::PinScope<'_, '_>) -> bool { + let global = scope.get_current_context().global(scope); + let armed = get_private_value(scope, global, INTERNAL_JAVASCRIPT_URL_EVAL_PERMIT_SLOT) + .is_some_and(|marker| marker.is_object()); + if !armed { + return false; + } + let empty = v8::undefined(scope); + set_private_value( + scope, + global, + INTERNAL_JAVASCRIPT_URL_EVAL_PERMIT_SLOT, + empty.into(), + ); + true +} diff --git a/moli-renderer-v8/src/context_bootstrap/trusted_types.rs b/moli-renderer-v8/src/context_bootstrap/trusted_types.rs index 0f2e117078..4c92fb4c99 100644 --- a/moli-renderer-v8/src/context_bootstrap/trusted_types.rs +++ b/moli-renderer-v8/src/context_bootstrap/trusted_types.rs @@ -265,6 +265,59 @@ pub(crate) fn trusted_script_string_for_script_element_execution( None } +/// Result of applying the Trusted Types pre-navigation conversion to a +/// decoded `javascript:` source. +/// +/// Owner-specific CSP event dispatch deliberately remains outside this +/// module. This layer owns only Trusted Type values and default-policy calls. +pub(crate) struct JavascriptUrlTrustedTypesCheck { + pub(crate) source: Option, + pub(crate) violated: bool, +} + +/// Apply the Trusted Types pre-navigation conversion for a decoded +/// `javascript:` source. Policy exceptions are consumed because this runs in +/// the later navigation task, not in the API call that queued the navigation. +pub(crate) fn check_javascript_url_trusted_types( + scope: &mut v8::PinScope<'_, '_>, + original: &str, + requirements: TrustedTypesForScriptRequirements, +) -> JavascriptUrlTrustedTypesCheck { + if !requirements.requires_conversion() { + return JavascriptUrlTrustedTypesCheck { + source: Some(original.to_owned()), + violated: false, + }; + } + let outcome = { + let try_catch = std::pin::pin!(v8::TryCatch::new(scope)); + let mut scope = try_catch.init(); + apply_default_trusted_type_policy_outcome( + &mut scope, + original, + TrustedTypeKind::Script, + "Location href", + ) + }; + match outcome { + DefaultTrustedTypePolicyOutcome::Value(value) + if url::Url::parse(&format!("javascript:{value}")).is_ok() => + { + JavascriptUrlTrustedTypesCheck { + source: Some(value), + violated: false, + } + } + DefaultTrustedTypePolicyOutcome::Value(_) + | DefaultTrustedTypePolicyOutcome::Unavailable + | DefaultTrustedTypePolicyOutcome::Rejected + | DefaultTrustedTypePolicyOutcome::Exception => JavascriptUrlTrustedTypesCheck { + source: (!requirements.is_enforced()).then(|| original.to_owned()), + violated: true, + }, + } +} + pub(crate) enum TrustedTypesCodeGenerationCheck { AllowOriginal, AllowModified(String), diff --git a/moli-renderer-v8/src/document_runtime/security_policy.rs b/moli-renderer-v8/src/document_runtime/security_policy.rs index 6971d3b803..a25b2702dd 100644 --- a/moli-renderer-v8/src/document_runtime/security_policy.rs +++ b/moli-renderer-v8/src/document_runtime/security_policy.rs @@ -777,7 +777,7 @@ impl DocumentRuntime { kind: ContentSecurityPolicyNonUrlKind, source: &str, ) -> DocumentContentSecurityPolicyCheck { - self.inline_source_csp_check_for_child_document( + self.inline_source_csp_check_for_document( Some(self.document_handle()), self.document_url(), &self.policy_container.response_content_security_policies, @@ -795,7 +795,7 @@ impl DocumentRuntime { source: &str, request: ContentSecurityPolicyScriptElementRequest<'_>, ) -> DocumentContentSecurityPolicyCheck { - self.inline_script_element_csp_check_for_child_document( + self.inline_script_element_csp_check_for_document( Some(self.document_handle()), self.document_url(), &self.policy_container.response_content_security_policies, @@ -929,7 +929,7 @@ impl DocumentRuntime { } #[allow(clippy::too_many_arguments)] - pub(crate) fn inline_script_element_csp_check_for_child_document( + pub(crate) fn inline_script_element_csp_check_for_document( &self, document_handle: Option, document_url: &Url, @@ -971,7 +971,7 @@ impl DocumentRuntime { } } - pub(crate) fn inline_source_csp_check_for_child_document( + pub(crate) fn inline_source_csp_check_for_document( &self, document_handle: Option, document_url: &Url, @@ -1011,6 +1011,7 @@ impl DocumentRuntime { } } + #[cfg(test)] pub(crate) fn document_frame_csp_violation( &self, request_url: &Url, @@ -1030,22 +1031,6 @@ impl DocumentRuntime { ) } - pub(crate) fn document_frame_csp_report_only_violation( - &self, - request_url: &Url, - ) -> Option { - document_frame_policy_violation( - &self - .policy_container - .response_content_security_report_only_policies, - &self.policy_container.content_security_reporting_endpoints, - self.document_url(), - request_url, - ContentSecurityPolicyRedirectStatus::NoRedirect, - ContentSecurityPolicyDisposition::Report, - ) - } - pub(crate) fn script_element_request_csp_violation_for_child_document( &self, document_handle: Option, @@ -1164,7 +1149,7 @@ impl DocumentRuntime { } } - pub(crate) fn document_frame_csp_violation_for_child_document( + pub(crate) fn document_frame_csp_violation_for_document( &self, document_handle: Option, document_url: &Url, @@ -1186,7 +1171,7 @@ impl DocumentRuntime { ) } - pub(crate) fn document_frame_csp_report_only_violation_for_child_document( + pub(crate) fn document_frame_csp_report_only_violation_for_document( &self, document_url: &Url, response_report_only_policies: &[String], @@ -1396,6 +1381,44 @@ impl DocumentRuntime { ) } + pub(crate) fn trusted_types_sink_csp_violations_for_document( + &self, + document_handle: Option, + document_url: &Url, + response_policies: &[String], + report_only_policies: &[String], + reporting_endpoints: &ContentSecurityPolicyReportingEndpoints, + sink: &str, + sample: &str, + ) -> Vec { + let report_only_policies = document_response_content_security_policy_strings( + report_only_policies, + reporting_endpoints, + ); + let enforced_policies = self + .document_content_security_policy_strings_for_optional_document( + document_handle, + response_policies, + reporting_endpoints, + ); + let mut violations = iter_document_trusted_types_sink_policy_violations( + enforced_policies, + document_url, + sink, + sample, + ContentSecurityPolicyDisposition::Enforce, + ) + .collect::>(); + violations.extend(iter_document_trusted_types_sink_policy_violations( + report_only_policies, + document_url, + sink, + sample, + ContentSecurityPolicyDisposition::Report, + )); + violations + } + pub(crate) fn requires_trusted_types_for_script(&self) -> bool { let policies = self.document_content_security_policy_strings_for_optional_document( Some(self.document_handle()), @@ -2131,7 +2154,24 @@ fn document_trusted_types_sink_policy_violation_from_document_policies( sample: &str, disposition: ContentSecurityPolicyDisposition, ) -> Option { - policies.into_iter().find_map(|policy| { + iter_document_trusted_types_sink_policy_violations( + policies, + document_url, + sink, + sample, + disposition, + ) + .next() +} + +fn iter_document_trusted_types_sink_policy_violations<'a>( + policies: Vec, + document_url: &'a Url, + sink: &'a str, + sample: &'a str, + disposition: ContentSecurityPolicyDisposition, +) -> impl Iterator + 'a { + policies.into_iter().filter_map(move |policy| { let single_policy = [policy.policy.clone()]; let mut violation = content_security_policy_trusted_types_sink_violation_with_disposition_and_reporting_endpoints( 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 f57b0d9000..3fa41d7ca0 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs @@ -200,6 +200,21 @@ enum LightweightPopupClassicScriptAdvance { Pending(PopupDocumentLoadBodyActivity), } +struct LightweightPopupDocumentProjectionSource<'a> { + task: LightweightPopupNavigationTaskToken, + document_url: &'a Url, + markup: &'a str, + content_type: Option<&'a str>, + character_set: Option<&'a str>, + policy_container: &'a DocumentPolicyContainer, +} + +struct LightweightPopupDocumentProjectionApplication { + document_handle: Option, + body_activity: PopupDocumentLoadBodyActivity, + classic_script_load_pending: bool, +} + pub(super) struct PendingLightweightPopupClassicScriptLoad { target: LightweightPopupClassicScriptFetchTarget, script_handle: DomHandle, @@ -2408,81 +2423,25 @@ impl JsContextHost { &base_url, &document_referrer, ); - if let Some(document) = - crate::dom_parser::parse_child_document_projection_from_source( - scope, - final_url.clone(), - &loaded.markup, - loaded.content_type.as_deref(), - Some(&loaded.character_set), - crate::parser::HtmlParser::with_scripting_enabled( - self.lightweight_popup_scripting_enabled(popup_id), - ), - ) - { - let host_ptr = self as *mut JsContextHost; - let document_base_url = lightweight_popup_parsed_document_base_url( - host_ptr, scope, document, &final_url, - ); - if let Some(document_record) = - self.lightweight_popup_document_record_mut(popup_id) - { - document_record.state.base_url = document_base_url.clone(); - } - let document_referrer = self - .lightweight_popup_document_record(popup_id) - .map(|document| document.state.policy_container.document_referrer.clone()) - .unwrap_or_default(); - sync_lightweight_popup_document_window_slots( - scope, - document, - window, - &document_base_url, - &document_referrer, - ); - set_object_slot(scope, window, "document", document.into()); - self.forget_lightweight_popup_document_handle(popup_id); - let popup_document_handle = - self.remember_lightweight_popup_document_handle(scope, popup_id, document); - if let Some(document_handle) = popup_document_handle { - let _ = crate::context_bootstrap::install_css_runtime_state_for_document( - scope, - window, - Some(document_handle), - ); - install_lightweight_popup_get_computed_style( - scope, - window, - document_handle, - ); - } - let _ = self.set_lightweight_popup_document_wrapper( - popup_id, - v8::Global::new(scope, document), - ); - let script_advance = self.execute_lightweight_popup_document_scripts( - scope, + if let Some(application) = self.install_lightweight_popup_document_projection( + scope, + window, + LightweightPopupDocumentProjectionSource { task, - popup_document_handle, - loaded.policy_container.sandbox.allows_scripts, - &loaded.policy_container.response_content_security_policies, - &loaded - .policy_container - .response_content_security_report_only_policies, - &loaded.policy_container.content_security_reporting_endpoints, - ); - body_activity = match script_advance { - LightweightPopupClassicScriptAdvance::Completed(activity) => activity, - LightweightPopupClassicScriptAdvance::Pending(activity) => { - classic_script_load_pending = true; - activity - } - }; + document_url: &final_url, + markup: &loaded.markup, + content_type: loaded.content_type.as_deref(), + character_set: Some(&loaded.character_set), + policy_container: &loaded.policy_container, + }, + ) { + body_activity = application.body_activity; + classic_script_load_pending = application.classic_script_load_pending; if !self.lightweight_popup_committed_navigation_task_is_current(task) { return PopupDocumentLoadApplication::Applied { body_activity }; } if !classic_script_load_pending - && let Some(document_handle) = popup_document_handle + && let Some(document_handle) = application.document_handle { self.sync_child_browsing_context_subtree(scope, document_handle); } @@ -2801,6 +2760,83 @@ impl JsContextHost { self.dispatch_lightweight_popup_window_event(scope, popup_id, "load", event); } + fn install_lightweight_popup_document_projection<'s>( + &mut self, + scope: &mut v8::PinScope<'s, '_>, + window: v8::Local<'s, v8::Object>, + source: LightweightPopupDocumentProjectionSource<'_>, + ) -> Option { + let popup_id = source.task.popup_id(); + let document = crate::dom_parser::parse_child_document_projection_from_source( + scope, + source.document_url.clone(), + source.markup, + source.content_type, + source.character_set, + crate::parser::HtmlParser::with_scripting_enabled( + self.lightweight_popup_scripting_enabled(popup_id), + ), + )?; + let host_ptr = self as *mut JsContextHost; + let document_base_url = lightweight_popup_parsed_document_base_url( + host_ptr, + scope, + document, + source.document_url, + ); + if let Some(document_record) = self.lightweight_popup_document_record_mut(popup_id) { + document_record.state.base_url = document_base_url.clone(); + } + // The committed record owns the navigation-derived referrer. The + // response policy container only carries response-derived policy and + // can still have an empty referrer here. + let document_referrer = self + .lightweight_popup_document_record(popup_id) + .map(|document| document.state.policy_container.document_referrer.clone()) + .unwrap_or_default(); + sync_lightweight_popup_document_window_slots( + scope, + document, + window, + &document_base_url, + &document_referrer, + ); + set_object_slot(scope, window, "document", document.into()); + self.forget_lightweight_popup_document_handle(popup_id); + let document_handle = + self.remember_lightweight_popup_document_handle(scope, popup_id, document); + if let Some(document_handle) = document_handle { + let _ = crate::context_bootstrap::install_css_runtime_state_for_document( + scope, + window, + Some(document_handle), + ); + install_lightweight_popup_get_computed_style(scope, window, document_handle); + } + let _ = + self.set_lightweight_popup_document_wrapper(popup_id, v8::Global::new(scope, document)); + let script_advance = self.execute_lightweight_popup_document_scripts( + scope, + source.task, + document_handle, + source.policy_container.sandbox.allows_scripts, + &source.policy_container.response_content_security_policies, + &source + .policy_container + .response_content_security_report_only_policies, + &source.policy_container.content_security_reporting_endpoints, + ); + let (body_activity, classic_script_load_pending) = match script_advance { + LightweightPopupClassicScriptAdvance::Completed(activity) => (activity, false), + LightweightPopupClassicScriptAdvance::Pending(activity) => (activity, true), + }; + Some(LightweightPopupDocumentProjectionApplication { + document_handle, + body_activity, + classic_script_load_pending, + }) + } + fn execute_lightweight_popup_document_scripts( &mut self, scope: &mut v8::PinScope<'_, '_>, @@ -3543,9 +3579,10 @@ impl JsContextHost { fn execute_lightweight_popup_window_javascript_url_source( &mut self, scope: &mut v8::PinScope<'_, '_>, - popup_id: u64, + task: LightweightPopupNavigationTaskToken, source: &str, ) -> Result<()> { + let popup_id = task.popup_id(); let Some(window) = self.lightweight_popup_window(scope, popup_id) else { anyhow::bail!("popup javascript URL has no window"); }; @@ -3562,7 +3599,7 @@ impl JsContextHost { } self.execute_lightweight_popup_window_javascript_url_source_in_context( popup_scope, - popup_id, + task, window, source, ) @@ -3571,37 +3608,133 @@ impl JsContextHost { fn execute_lightweight_popup_window_javascript_url_source_in_context<'s>( &mut self, scope: &mut v8::PinScope<'s, '_>, - popup_id: u64, + task: LightweightPopupNavigationTaskToken, window: v8::Local<'s, v8::Object>, source: &str, ) -> Result<()> { - let Some(source_value) = v8_string(scope, source) else { + let popup_id = task.popup_id(); + let owner = OwnerDispatchScope::LightweightPopup(popup_id); + let Some(source) = self.prepare_javascript_url_source_for_execution(scope, owner, source) + else { + return Ok(()); + }; + let Some(source_value) = v8_string(scope, &source) else { anyhow::bail!("failed to allocate popup javascript URL source"); }; - let mut script_source = v8::script_compiler::Source::new(source_value, None); - let Some(function) = v8::script_compiler::compile_function( + let Some(wrapper_source) = v8_string( scope, - &mut script_source, - &[], - &[window], - v8::script_compiler::CompileOptions::NoCompileOptions, - v8::script_compiler::NoCacheReason::BecauseInlineScript, + "(function(window, __moliSource) { with (window) { return eval(__moliSource); } })", ) else { - anyhow::bail!("v8 failed to compile popup javascript URL"); + anyhow::bail!("failed to allocate popup javascript URL wrapper"); + }; + let Some(function) = v8::Script::compile(scope, wrapper_source, None) + .and_then(|script| script.run(scope)) + .and_then(|value| v8::Local::::try_from(value).ok()) + else { + anyhow::bail!("v8 failed to compile popup javascript URL wrapper"); }; let previous_popup = enter_active_lightweight_popup_scope(scope, popup_id); let previous_message_source = self.enter_window_message_source_scope( super::PendingWindowMessageEndpoint::LightweightPopup(popup_id), ); - let run_succeeded = function.call(scope, window.into(), &[]).is_some(); + let eval_permit = crate::context_bootstrap::arm_internal_javascript_url_eval(scope); + let completion = function.call(scope, window.into(), &[window.into(), source_value.into()]); + crate::context_bootstrap::restore_internal_javascript_url_eval(scope, eval_permit); self.restore_window_message_source_scope(previous_message_source); restore_active_lightweight_popup_scope(scope, previous_popup); - if !run_succeeded { + let Some(completion) = completion else { anyhow::bail!("v8 failed to execute popup javascript URL"); + }; + if completion.is_string() + && self.lightweight_popup_committed_navigation_task_is_current(task) + { + let markup = completion + .to_string(scope) + .map(|value| value.to_rust_string_lossy(scope)) + .unwrap_or_default(); + self.commit_lightweight_popup_javascript_url_string_completion( + scope, task, window, markup, + ); } Ok(()) } + fn commit_lightweight_popup_javascript_url_string_completion<'s>( + &mut self, + scope: &mut v8::PinScope<'s, '_>, + task: LightweightPopupNavigationTaskToken, + window: v8::Local<'s, v8::Object>, + markup: String, + ) -> bool { + if !self.lightweight_popup_committed_navigation_task_is_current(task) { + return false; + } + let popup_id = task.popup_id(); + let Some(document_url) = self + .lightweight_popup_document_record(popup_id) + .map(|document| document.url.clone()) + else { + return false; + }; + let Some((document_state, storage_scope)) = + self.lightweight_popup_document_commit_seed(popup_id) + else { + return false; + }; + let policy_container = document_state.policy_container.clone(); + if !self.commit_lightweight_popup_document( + scope, + window, + LightweightPopupDocumentCommit { + owner: task.document_owner(), + location_url: document_url.clone(), + origin: LightweightPopupDocumentCommitOrigin::RetainCurrent, + state: document_state, + storage_scope, + navigation_loader: None, + }, + ) || !self.lightweight_popup_committed_navigation_task_is_current(task) + { + return false; + } + let base_url = self + .lightweight_popup_base_url(scope, popup_id) + .unwrap_or_else(|| document_url.clone()); + sync_lightweight_popup_window_location( + scope, + window, + document_url.as_str(), + &base_url, + &policy_container.document_referrer, + ); + let Some(application) = self.install_lightweight_popup_document_projection( + scope, + window, + LightweightPopupDocumentProjectionSource { + task, + document_url: &document_url, + markup: &markup, + content_type: Some("text/html"), + character_set: Some("UTF-8"), + policy_container: &policy_container, + }, + ) else { + return false; + }; + if !self.lightweight_popup_committed_navigation_task_is_current(task) { + return true; + } + if !application.classic_script_load_pending { + if let Some(document_handle) = application.document_handle { + self.sync_child_browsing_context_subtree(scope, document_handle); + } + if self.lightweight_popup_committed_navigation_task_is_current(task) { + self.queue_lightweight_popup_load_event(task); + } + } + true + } + fn queue_lightweight_popup_load_event(&mut self, task: LightweightPopupNavigationTaskToken) { if !self.lightweight_popup_committed_navigation_task_is_current(task) { return; @@ -4658,7 +4791,7 @@ fn lightweight_popup_javascript_url_callback<'s>( return; } if let Err(error) = - host.execute_lightweight_popup_window_javascript_url_source(scope, popup_id, &source) + host.execute_lightweight_popup_window_javascript_url_source(scope, task, &source) { tracing::debug!( popup_id, 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 7d401eaa08..74339f7254 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 @@ -8,9 +8,9 @@ use crate::{ context_bootstrap::CHILD_BROWSING_CONTEXT_HANDLE_SLOT, document_runtime::{ DocumentContentSecurityPolicyCheck, DocumentContentSecurityPolicyViolation, - DocumentSubresourceCspKind, DomHandle, create_content_security_policy_violation_event, + DocumentPolicyContainer, DocumentSubresourceCspKind, DomHandle, + create_content_security_policy_violation_event, }, - dom::native::Node, native_bridge::{ active_child_window_handle, active_lightweight_popup_id, child_window_handle_from_marker_data, entered_child_window_handle, @@ -26,6 +26,15 @@ pub(crate) enum DocumentCspOutcome { SkippedChildContext, } +#[derive(Debug, Clone)] +struct OwnerDocumentPolicySnapshot { + document_handle: Option, + document_url: url::Url, + policy_container: DocumentPolicyContainer, +} + +const JAVASCRIPT_URL_TRUSTED_TYPES_SINK: &str = "Location href"; + fn policy_child_window_handle(scope: &mut v8::PinScope<'_, '_>) -> Option { if let Some(handle) = active_child_window_handle(scope) { return Some(handle); @@ -49,6 +58,43 @@ impl DocumentCspOutcome { } impl JsContextHost { + fn owner_document_policy_snapshot( + &self, + owner: OwnerDispatchScope, + ) -> Option { + match owner { + OwnerDispatchScope::Top => { + // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. + let runtime = unsafe { &*self.runtime }; + Some(OwnerDocumentPolicySnapshot { + document_handle: Some(runtime.document_handle()), + document_url: runtime.document_url().clone(), + policy_container: runtime.document_policy_container().clone(), + }) + } + OwnerDispatchScope::Child(handle) => { + let mut policy_container = + self.child_browsing_context_policy_container_snapshot(handle)?; + policy_container.response_content_security_policies = + self.child_effective_response_content_security_policies(handle); + policy_container.response_content_security_report_only_policies = + self.child_effective_response_content_security_report_only_policies(handle); + policy_container.content_security_reporting_endpoints = + self.child_effective_content_security_reporting_endpoints(handle); + Some(OwnerDocumentPolicySnapshot { + document_handle: self.child_browsing_context_document_handle(handle), + document_url: self.child_browsing_context_current_url(handle)?, + policy_container, + }) + } + OwnerDispatchScope::LightweightPopup(popup_id) => Some(OwnerDocumentPolicySnapshot { + document_handle: self.lightweight_popup_document_handle(popup_id), + document_url: self.lightweight_popup_document_url(popup_id)?, + policy_container: self.lightweight_popup_policy_container(popup_id)?.clone(), + }), + } + } + pub(crate) fn document_policy_container( &self, ) -> &crate::document_runtime::DocumentPolicyContainer { @@ -60,57 +106,91 @@ impl JsContextHost { &self, owner: OwnerDispatchScope, ) -> Option { - let policy = match owner { - OwnerDispatchScope::Top => self.document_policy_container().clone(), - OwnerDispatchScope::Child(handle) => { - self.child_browsing_context_policy_container_snapshot(handle)? - } - OwnerDispatchScope::LightweightPopup(popup_id) => { - self.lightweight_popup_policy_container(popup_id)?.clone() - } - }; - Some(crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container(&policy)) + let snapshot = self.owner_document_policy_snapshot(owner)?; + Some( + crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container( + &snapshot.policy_container, + ), + ) } pub(crate) fn trusted_types_for_script_requirements_for_owner( &self, owner: OwnerDispatchScope, ) -> Option { - match owner { - OwnerDispatchScope::Top => { - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - Some(unsafe { &*self.runtime }.trusted_types_for_script_requirements()) - } - OwnerDispatchScope::Child(handle) => { - let response_policies = - self.child_effective_response_content_security_policies(handle); - let report_only_policies = - self.child_effective_response_content_security_report_only_policies(handle); - let reporting_endpoints = - self.child_effective_content_security_reporting_endpoints(handle); - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - Some( - unsafe { &*self.runtime }.trusted_types_for_script_requirements_for_document( - self.child_browsing_context_document_handle(handle), - &response_policies, - &report_only_policies, - &reporting_endpoints, - ), - ) - } - OwnerDispatchScope::LightweightPopup(popup_id) => { - let policy = self.lightweight_popup_policy_container(popup_id)?; - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - Some( - unsafe { &*self.runtime }.trusted_types_for_script_requirements_for_document( - self.lightweight_popup_document_handle(popup_id), - &policy.response_content_security_policies, - &policy.response_content_security_report_only_policies, - &policy.content_security_reporting_endpoints, - ), - ) - } + let snapshot = self.owner_document_policy_snapshot(owner)?; + // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. + Some( + unsafe { &*self.runtime }.trusted_types_for_script_requirements_for_document( + snapshot.document_handle, + &snapshot.policy_container.response_content_security_policies, + &snapshot + .policy_container + .response_content_security_report_only_policies, + &snapshot + .policy_container + .content_security_reporting_endpoints, + ), + ) + } + + fn trusted_types_sink_csp_violations_for_owner( + &self, + owner: OwnerDispatchScope, + sink: &str, + sample: &str, + ) -> Vec { + let Some(snapshot) = self.owner_document_policy_snapshot(owner) else { + return Vec::new(); + }; + // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. + unsafe { &*self.runtime }.trusted_types_sink_csp_violations_for_document( + snapshot.document_handle, + &snapshot.document_url, + &snapshot.policy_container.response_content_security_policies, + &snapshot + .policy_container + .response_content_security_report_only_policies, + &snapshot + .policy_container + .content_security_reporting_endpoints, + sink, + sample, + ) + } + + /// Run the target Document checks immediately before a `javascript:` URL + /// executes. Source-context admission checks may already have happened at + /// the navigation API boundary; this is the Chromium-style target check + /// shared by top-level, child-frame, and lightweight-popup executors. + pub(crate) fn prepare_javascript_url_source_for_execution<'s>( + &mut self, + scope: &mut v8::PinScope<'s, '_>, + owner: OwnerDispatchScope, + source: &str, + ) -> Option { + let csp_source = format!("javascript:{source}"); + if !self.allows_inline_javascript_navigation_by_csp(scope, owner, &csp_source) { + return None; } + + let requirements = self.trusted_types_for_script_requirements_for_owner(owner)?; + let check = crate::context_bootstrap::check_javascript_url_trusted_types( + scope, + source, + requirements, + ); + if check.violated { + let host_ptr: *mut JsContextHost = self; + self.dispatch_trusted_types_sink_csp_violation_event_for_owner_without_stack_best_effort( + scope, + host_ptr, + owner, + JAVASCRIPT_URL_TRUSTED_TYPES_SINK, + source, + ); + } + check.source } pub(crate) fn cross_origin_embedder_policy( @@ -256,7 +336,7 @@ impl JsContextHost { kind: ContentSecurityPolicyNonUrlKind, source: &str, ) -> bool { - let Some(check) = self.inline_source_csp_check_for_owner(scope, owner, kind, source) else { + let Some(check) = self.inline_source_csp_check_for_owner(owner, kind, source) else { // Deliberately fail open only while a child or lightweight popup // has no active document URL/policy context (for example during a // navigation swap). CSP is document-scoped: applying the top-level @@ -282,54 +362,27 @@ impl JsContextHost { fn inline_source_csp_check_for_owner( &self, - _scope: &mut v8::PinScope<'_, '_>, owner: OwnerDispatchScope, kind: ContentSecurityPolicyNonUrlKind, source: &str, ) -> Option { - match owner { - OwnerDispatchScope::Top => { - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - Some(unsafe { &*self.runtime }.inline_source_csp_check(kind, source)) - } - OwnerDispatchScope::Child(handle) => { - let document_url = self.child_browsing_context_current_url(handle)?; - let response_policies = - self.child_effective_response_content_security_policies(handle); - let response_report_only_policies = - self.child_effective_response_content_security_report_only_policies(handle); - let response_reporting_endpoints = - self.child_effective_content_security_reporting_endpoints(handle); - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - Some( - unsafe { &*self.runtime }.inline_source_csp_check_for_child_document( - self.child_browsing_context_document_handle(handle), - &document_url, - &response_policies, - &response_report_only_policies, - &response_reporting_endpoints, - kind, - source, - ), - ) - } - OwnerDispatchScope::LightweightPopup(popup_id) => { - let document_url = self.lightweight_popup_document_url(popup_id)?; - let policy_container = self.lightweight_popup_policy_container(popup_id)?; - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - Some( - unsafe { &*self.runtime }.inline_source_csp_check_for_child_document( - self.lightweight_popup_document_handle(popup_id), - &document_url, - &policy_container.response_content_security_policies, - &policy_container.response_content_security_report_only_policies, - &policy_container.content_security_reporting_endpoints, - kind, - source, - ), - ) - } - } + let snapshot = self.owner_document_policy_snapshot(owner)?; + // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. + Some( + unsafe { &*self.runtime }.inline_source_csp_check_for_document( + snapshot.document_handle, + &snapshot.document_url, + &snapshot.policy_container.response_content_security_policies, + &snapshot + .policy_container + .response_content_security_report_only_policies, + &snapshot + .policy_container + .content_security_reporting_endpoints, + kind, + source, + ), + ) } fn inline_script_element_csp_check_for_owner( @@ -338,47 +391,23 @@ impl JsContextHost { source: &str, request: ContentSecurityPolicyScriptElementRequest<'_>, ) -> Option { - match owner { - OwnerDispatchScope::Top => { - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - Some(unsafe { &*self.runtime }.inline_script_element_csp_check(source, request)) - } - OwnerDispatchScope::Child(handle) => { - let document_url = self.child_browsing_context_current_url(handle)?; - let response_policies = - self.child_effective_response_content_security_policies(handle); - let response_report_only_policies = - self.child_effective_response_content_security_report_only_policies(handle); - let response_reporting_endpoints = - self.child_effective_content_security_reporting_endpoints(handle); - Some( - unsafe { &*self.runtime }.inline_script_element_csp_check_for_child_document( - self.child_browsing_context_document_handle(handle), - &document_url, - &response_policies, - &response_report_only_policies, - &response_reporting_endpoints, - source, - request, - ), - ) - } - OwnerDispatchScope::LightweightPopup(popup_id) => { - let document_url = self.lightweight_popup_document_url(popup_id)?; - let policy_container = self.lightweight_popup_policy_container(popup_id)?; - Some( - unsafe { &*self.runtime }.inline_script_element_csp_check_for_child_document( - self.lightweight_popup_document_handle(popup_id), - &document_url, - &policy_container.response_content_security_policies, - &policy_container.response_content_security_report_only_policies, - &policy_container.content_security_reporting_endpoints, - source, - request, - ), - ) - } - } + let snapshot = self.owner_document_policy_snapshot(owner)?; + // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. + Some( + unsafe { &*self.runtime }.inline_script_element_csp_check_for_document( + snapshot.document_handle, + &snapshot.document_url, + &snapshot.policy_container.response_content_security_policies, + &snapshot + .policy_container + .response_content_security_report_only_policies, + &snapshot + .policy_container + .content_security_reporting_endpoints, + source, + request, + ), + ) } pub(crate) fn entered_owner_dispatch_scope( @@ -545,27 +574,18 @@ impl JsContextHost { frame_handle: DomHandle, request_url: &url::Url, ) -> Option { - match self.frame_navigation_source_child_handle(frame_handle)? { - Some(source_child) => { - let document_url = self.child_browsing_context_current_url(source_child)?; - let response_policies = - self.child_effective_response_content_security_policies(source_child); - let response_reporting_endpoints = - self.child_effective_content_security_reporting_endpoints(source_child); - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - unsafe { &*self.runtime }.document_frame_csp_violation_for_child_document( - self.child_browsing_context_document_handle(source_child), - &document_url, - &response_policies, - &response_reporting_endpoints, - request_url, - ) - } - None => { - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - unsafe { &*self.runtime }.document_frame_csp_violation(request_url) - } - } + let owner = self.owner_dispatch_scope_for_node(frame_handle)?; + let snapshot = self.owner_document_policy_snapshot(owner)?; + // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. + unsafe { &*self.runtime }.document_frame_csp_violation_for_document( + snapshot.document_handle, + &snapshot.document_url, + &snapshot.policy_container.response_content_security_policies, + &snapshot + .policy_container + .content_security_reporting_endpoints, + request_url, + ) } pub(crate) fn frame_navigation_csp_report_only_violation( @@ -573,42 +593,19 @@ impl JsContextHost { frame_handle: DomHandle, request_url: &url::Url, ) -> Option { - match self.frame_navigation_source_child_handle(frame_handle)? { - Some(source_child) => { - let document_url = self.child_browsing_context_current_url(source_child)?; - let response_report_only_policies = self - .child_effective_response_content_security_report_only_policies(source_child); - let response_reporting_endpoints = - self.child_effective_content_security_reporting_endpoints(source_child); - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - unsafe { &*self.runtime } - .document_frame_csp_report_only_violation_for_child_document( - &document_url, - &response_report_only_policies, - &response_reporting_endpoints, - request_url, - ) - } - None => { - // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. - unsafe { &*self.runtime }.document_frame_csp_report_only_violation(request_url) - } - } - } - - fn frame_navigation_source_child_handle( - &self, - frame_handle: DomHandle, - ) -> Option> { - let owner_document = self - .dom_host() - .node(frame_handle) - .and_then(Node::owner_document)?; - if owner_document == self.document_handle() { - return Some(None); - } - self.child_browsing_context_host_for_document_handle(owner_document) - .map(Some) + let owner = self.owner_dispatch_scope_for_node(frame_handle)?; + let snapshot = self.owner_document_policy_snapshot(owner)?; + // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. + unsafe { &*self.runtime }.document_frame_csp_report_only_violation_for_document( + &snapshot.document_url, + &snapshot + .policy_container + .response_content_security_report_only_policies, + &snapshot + .policy_container + .content_security_reporting_endpoints, + request_url, + ) } fn child_effective_response_content_security_policies( @@ -1072,6 +1069,23 @@ impl JsContextHost { ); } + pub(crate) fn dispatch_trusted_types_sink_csp_violation_event_for_owner_without_stack_best_effort< + 's, + >( + &mut self, + scope: &mut v8::PinScope<'s, '_>, + host_ptr: *mut JsContextHost, + owner: OwnerDispatchScope, + sink: &str, + sample: &str, + ) { + for violation in self.trusted_types_sink_csp_violations_for_owner(owner, sink, sample) { + self.dispatch_content_security_policy_violation_event_for_owner_best_effort( + scope, host_ptr, owner, &violation, + ); + } + } + fn dispatch_trusted_types_sink_csp_violation_event_with_location_best_effort<'s>( &mut self, scope: &mut v8::PinScope<'s, '_>, @@ -1270,15 +1284,15 @@ impl JsContextHost { frame_handle: DomHandle, violation: &DocumentContentSecurityPolicyViolation, ) { - match self.frame_navigation_source_child_handle(frame_handle) { - Some(Some(source_child)) => { + match self.owner_dispatch_scope_for_node(frame_handle) { + Some(OwnerDispatchScope::Child(source_child)) => { self.dispatch_child_content_security_policy_violation_event_best_effort( scope, source_child, violation, ); } - Some(None) => { + Some(OwnerDispatchScope::Top) => { let host_ptr: *mut JsContextHost = self; // SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime. unsafe { &mut *self.runtime } @@ -1286,6 +1300,11 @@ impl JsContextHost { scope, host_ptr, violation, ); } + Some(OwnerDispatchScope::LightweightPopup(popup_id)) => { + self.dispatch_lightweight_popup_content_security_policy_violation_event_best_effort( + scope, popup_id, violation, + ); + } None => { tracing::error!( blocked_uri = violation.blocked_uri.as_str(), diff --git a/moli-renderer-v8/src/runtime/owner.rs b/moli-renderer-v8/src/runtime/owner.rs index 9593214f9c..60a3045448 100644 --- a/moli-renderer-v8/src/runtime/owner.rs +++ b/moli-renderer-v8/src/runtime/owner.rs @@ -628,6 +628,17 @@ struct RuntimeExpressionAwaitSpec { navigation_policy: RuntimeEvaluationNavigationPolicy, } +struct RendererPostResponseOwnerWork { + document_lifecycle: Option, + navigation_handoff: Option, +} + +impl RendererPostResponseOwnerWork { + const fn is_empty(&self) -> bool { + self.document_lifecycle.is_none() && self.navigation_handoff.is_none() + } +} + fn checked_live_page_wait_deadline(timeout_ms: u64, operation: &str) -> Result { let timeout = std::time::Duration::from_millis(timeout_ms); Instant::now() @@ -1597,7 +1608,7 @@ impl RendererOwnerHandle { Ok(entry) => entry, Err(error) => return Err(error).into(), }; - entry.begin_standalone_navigation_follow(); + entry.begin_renderer_navigation_follow(); self.restore_live_page_entry(token, entry); } match self @@ -1957,16 +1968,23 @@ impl RendererOwnerHandle { ); } - fn post_response_document_lifecycle_continuation( + fn post_response_owner_work_continuation( &self, token: RendererPageToken, - document: RendererDocumentLifecycleIdentity, + work: RendererPostResponseOwnerWork, ) -> RendererPageCommandPostResponseContinuation { let page_wake_tx = self.state.page_wake_tx.clone(); RendererPageCommandPostResponseContinuation::new(move || { - let _ = page_wake_tx.send(RendererOwnerWake::post_response_document_lifecycle( - token, document, - )); + if let Some(document) = work.document_lifecycle { + let _ = page_wake_tx.send(RendererOwnerWake::post_response_document_lifecycle( + token, document, + )); + } + if let Some(handoff) = work.navigation_handoff { + let _ = page_wake_tx.send(RendererOwnerWake::top_level_navigation_handoff( + token, handoff, + )); + } }) } @@ -3380,7 +3398,7 @@ impl RendererOwnerHandle { mut entry: RetiringPageEntry, error: anyhow::Error, ) -> RenderRuntimeDispatchOutcome { - entry.settle_standalone_navigation_follow(false); + entry.settle_renderer_navigation_follow(false); tracing::warn!( page_id = token.page_id.as_u64(), failure = %error, @@ -3407,7 +3425,7 @@ impl RendererOwnerHandle { "retiring page after committed navigation failed to bootstrap" ); let cleanup_failure = disposition.to_string(); - entry.settle_standalone_navigation_follow(false); + entry.settle_renderer_navigation_follow(false); remove_page_on_bound_owner_local_store(token); let entry = entry.reject_and_retire(&cleanup_failure); restore_retiring_entry_after_command_on_bound_owner_local_store(token, entry); @@ -3458,7 +3476,7 @@ impl RendererOwnerHandle { ); remove_page_on_bound_owner_local_store(token); } - entry.settle_standalone_navigation_follow(false); + entry.settle_renderer_navigation_follow(false); self.restore_live_page_entry(token, entry); disposition.into_dispatch_outcome(token, page_creation_publication) } @@ -3784,7 +3802,7 @@ impl RendererOwnerHandle { return RenderRuntimeDispatchOutcome::BackgroundComplete(Ok(())); } }; - let claimed = entry.begin_standalone_navigation_follow_from_handoff(handoff); + let claimed = entry.begin_renderer_navigation_follow_from_handoff(handoff); self.restore_live_page_entry(token, entry); if !claimed { tracing::trace!( @@ -4212,7 +4230,7 @@ impl RendererOwnerHandle { action, DocumentLifecycleTurnAction::RequestedTopLevelNavigation { .. } ) && entry - .begin_standalone_navigation_follow(); + .begin_renderer_navigation_follow(); let delegated_top_level_navigation = if matches!( action, DocumentLifecycleTurnAction::RequestedTopLevelNavigation { .. } @@ -4885,7 +4903,7 @@ impl RendererOwnerHandle { mut entry: LivePageEntry, completion: LivePagePendingNavigationCompletion, ) -> RenderRuntimeDispatchOutcome { - entry.settle_standalone_navigation_follow(true); + entry.settle_renderer_navigation_follow(true); match completion { LivePagePendingNavigationCompletion::Background | LivePagePendingNavigationCompletion::PublishedPageCreation { .. } => { @@ -5001,7 +5019,7 @@ impl RendererOwnerHandle { completion: LivePagePendingNavigationCompletion, download: RendererPendingDownloadActivation, ) -> RenderRuntimeDispatchOutcome { - entry.settle_standalone_navigation_follow(true); + entry.settle_renderer_navigation_follow(true); match completion { LivePagePendingNavigationCompletion::Background | LivePagePendingNavigationCompletion::PublishedPageCreation { .. } => { @@ -5051,7 +5069,7 @@ impl RendererOwnerHandle { completion: LivePagePendingNavigationCompletion, ) -> RenderRuntimeDispatchOutcome { if follow_count == 0 { - entry.begin_standalone_navigation_follow(); + entry.begin_renderer_navigation_follow(); } self.restore_live_page_entry(token, entry); RenderRuntimeDispatchOutcome::ContinueNextTurn(Box::new( @@ -5450,20 +5468,25 @@ impl RendererOwnerHandle { }, ); } - let post_response_continuation = - match replacement_lifecycle.map(|outcome| outcome.readiness) { - Some( - DocumentLifecycleTurnReadiness::Runnable { document } - | DocumentLifecycleTurnReadiness::Blocked { document }, - ) => { - if let Err(error) = entry.defer_document_lifecycle_until_response(document) { - self.restore_live_page_entry(token, entry); - return Err(error).into(); - } - Some(self.post_response_document_lifecycle_continuation(token, document)) + let deferred_document = match replacement_lifecycle.map(|outcome| outcome.readiness) { + Some( + DocumentLifecycleTurnReadiness::Runnable { document } + | DocumentLifecycleTurnReadiness::Blocked { document }, + ) => { + if let Err(error) = entry.defer_document_lifecycle_until_response(document) { + self.restore_live_page_entry(token, entry); + return Err(error).into(); } - Some(DocumentLifecycleTurnReadiness::Idle) | None => None, - }; + Some(document) + } + Some(DocumentLifecycleTurnReadiness::Idle) | None => None, + }; + let post_response_work = RendererPostResponseOwnerWork { + document_lifecycle: deferred_document, + navigation_handoff: entry.pending_javascript_navigation_handoff(), + }; + let post_response_continuation = (!post_response_work.is_empty()) + .then(|| self.post_response_owner_work_continuation(token, post_response_work)); self.finish_live_page_entry_with_page_state_and_continuation( token, entry, @@ -6086,7 +6109,7 @@ impl RendererOwnerHandle { Err(error) => return Err(error).into(), }; if follow_count == 0 { - entry.begin_standalone_navigation_follow(); + entry.begin_renderer_navigation_follow(); } let retire_page_on_failure = completion.retires_page_on_navigation_failure(); if follow_count >= MAX_PENDING_LOCATION_NAVIGATION_TURNS { diff --git a/moli-renderer-v8/src/runtime/owner/lifecycle_decision.rs b/moli-renderer-v8/src/runtime/owner/lifecycle_decision.rs index 3f43824de5..c24837245f 100644 --- a/moli-renderer-v8/src/runtime/owner/lifecycle_decision.rs +++ b/moli-renderer-v8/src/runtime/owner/lifecycle_decision.rs @@ -233,7 +233,7 @@ impl RendererOwnerHandle { )); } if entry.page_vm().vm().has_pending_location_navigation() - && entry.begin_standalone_navigation_follow() + && entry.begin_renderer_navigation_follow() { return self.continue_live_page_pending_navigation( token, diff --git a/moli-renderer-v8/src/runtime/owner_local_store/entry.rs b/moli-renderer-v8/src/runtime/owner_local_store/entry.rs index 47f8af595d..5494f03001 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/entry.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/entry.rs @@ -2,7 +2,7 @@ use super::navigation_follow::CommittedNavigationBootstrapCompletion; use super::*; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(super) enum StandaloneNavigationFollowState { +pub(super) enum RendererNavigationFollowState { #[default] Idle, Following { @@ -21,7 +21,7 @@ pub(in crate::runtime) struct PublishedReplacementDocument { pub(in crate::runtime) view_generation: u64, } -impl StandaloneNavigationFollowState { +impl RendererNavigationFollowState { pub(super) fn claim( &mut self, current: Option, @@ -65,7 +65,7 @@ impl StandaloneNavigationFollowState { pub(in crate::runtime) struct LivePageEntry { pub(in crate::runtime) slot: RendererPageSlotHandle, pub(super) top_level_navigation_dispatch: RendererTopLevelNavigationDispatch, - pub(super) standalone_navigation_follow: StandaloneNavigationFollowState, + pub(super) renderer_navigation_follow: RendererNavigationFollowState, // Keep executable continuation state before `vm`: Rust drops fields in // declaration order, so an exceptional entry drop still releases any // ScriptVm-bound task before releasing the PageVm itself. @@ -180,9 +180,9 @@ impl CommittedNavigationEntry { RetiringPageEntry::new(self.entry) } - pub(in crate::runtime) fn settle_standalone_navigation_follow(&mut self, succeeded: bool) { + pub(in crate::runtime) fn settle_renderer_navigation_follow(&mut self, succeeded: bool) { self.entry - .standalone_navigation_follow + .renderer_navigation_follow .settle(None, succeeded); } } @@ -196,8 +196,8 @@ impl RetiringPageEntry { Self { entry } } - pub(in crate::runtime) fn settle_standalone_navigation_follow(&mut self, succeeded: bool) { - self.entry.settle_standalone_navigation_follow(succeeded); + pub(in crate::runtime) fn settle_renderer_navigation_follow(&mut self, succeeded: bool) { + self.entry.settle_renderer_navigation_follow(succeeded); } } @@ -210,8 +210,8 @@ impl std::fmt::Debug for LivePageEntry { &self.top_level_navigation_dispatch, ) .field( - "standalone_navigation_follow", - &self.standalone_navigation_follow, + "renderer_navigation_follow", + &self.renderer_navigation_follow, ) .field("vm", &self.vm) .field( @@ -244,7 +244,7 @@ impl LivePageEntry { slot, top_level_navigation_dispatch: RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter, - standalone_navigation_follow: StandaloneNavigationFollowState::Idle, + renderer_navigation_follow: RendererNavigationFollowState::Idle, pending_document_lifecycle_turn: None, post_response_document_lifecycle: None, vm: Some(vm), @@ -264,7 +264,7 @@ impl LivePageEntry { slot, top_level_navigation_dispatch: RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter, - standalone_navigation_follow: StandaloneNavigationFollowState::Idle, + renderer_navigation_follow: RendererNavigationFollowState::Idle, pending_document_lifecycle_turn: None, post_response_document_lifecycle: None, vm: None, @@ -294,45 +294,60 @@ impl LivePageEntry { self.top_level_navigation_dispatch } - /// Claim the single standalone owner chain for the current pending - /// location navigation. A failed chain remains suppressed while that same - /// descriptor is pending, so a duplicate producer handoff cannot restart - /// the chain with a fresh limit. - pub(in crate::runtime) fn begin_standalone_navigation_follow(&mut self) -> bool { - self.begin_standalone_navigation_follow_for_handoff(None) + pub(in crate::runtime) fn pending_javascript_navigation_handoff( + &self, + ) -> Option { + let vm = self.active_page_vm()?.vm(); + vm.pending_location_navigation_scheme_is("javascript") + .then(|| vm.pending_location_navigation_handoff()) + .flatten() + } + + fn renderer_owned_pending_navigation_handoff( + &self, + ) -> Option { + if matches!( + self.top_level_navigation_dispatch, + RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter + ) { + return self + .active_page_vm() + .and_then(|page_vm| page_vm.vm().pending_location_navigation_handoff()); + } + self.pending_javascript_navigation_handoff() + } + + /// Claim the renderer-owned chain for the current pending location + /// navigation. Standalone pages keep every navigation in the renderer; + /// delegated pages retain schemes such as `javascript:` that must not + /// cross the browser navigation boundary. + pub(in crate::runtime) fn begin_renderer_navigation_follow(&mut self) -> bool { + self.begin_renderer_navigation_follow_for_handoff(None) } /// Claim a producer handoff only while the same request still occupies /// the ScriptVm's unique pending navigation slot. A delayed wake for an /// overwritten request therefore cannot start the replacement request. - pub(in crate::runtime) fn begin_standalone_navigation_follow_from_handoff( + pub(in crate::runtime) fn begin_renderer_navigation_follow_from_handoff( &mut self, handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff, ) -> bool { - self.begin_standalone_navigation_follow_for_handoff(Some(handoff)) + self.begin_renderer_navigation_follow_for_handoff(Some(handoff)) } - pub(super) fn begin_standalone_navigation_follow_for_handoff( + pub(super) fn begin_renderer_navigation_follow_for_handoff( &mut self, requested: Option, ) -> bool { - if !matches!( - self.top_level_navigation_dispatch, - RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter - ) { - return false; - } - let current = self - .active_page_vm() - .and_then(|page_vm| page_vm.vm().pending_location_navigation_handoff()); - self.standalone_navigation_follow.claim(current, requested) + let current = self.renderer_owned_pending_navigation_handoff(); + self.renderer_navigation_follow.claim(current, requested) } - pub(in crate::runtime) fn settle_standalone_navigation_follow(&mut self, succeeded: bool) { + pub(in crate::runtime) fn settle_renderer_navigation_follow(&mut self, succeeded: bool) { let current = self .active_page_vm() .and_then(|page_vm| page_vm.vm().pending_location_navigation_handoff()); - self.standalone_navigation_follow.settle(current, succeeded); + self.renderer_navigation_follow.settle(current, succeeded); } /// Commit the source Document synchronously. The navigation task guard diff --git a/moli-renderer-v8/src/runtime/owner_local_store/mod.rs b/moli-renderer-v8/src/runtime/owner_local_store/mod.rs index 1ad8f0daec..8ff1deda54 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/mod.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/mod.rs @@ -55,7 +55,7 @@ mod tests; pub(in crate::runtime) use bound::*; #[cfg(test)] -use entry::StandaloneNavigationFollowState; +use entry::RendererNavigationFollowState; pub(in crate::runtime) use entry::{ CommittedNavigationEntry, LivePageEntry, PublishedReplacementDocument, RetiringPageEntry, }; diff --git a/moli-renderer-v8/src/runtime/owner_local_store/tests.rs b/moli-renderer-v8/src/runtime/owner_local_store/tests.rs index f0b7046847..af941af933 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/tests.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/tests.rs @@ -87,7 +87,7 @@ mod navigation_dispatch_tests { } fn phase_one_entry_shell_without_active_page_vm( - standalone_navigation_follow: StandaloneNavigationFollowState, + renderer_navigation_follow: RendererNavigationFollowState, ) -> LivePageEntry { let page_id = PageId::new_for_testing(1); let (page_context_cancel_tx, _page_context_cancel_rx) = @@ -102,7 +102,7 @@ mod navigation_dispatch_tests { slot, top_level_navigation_dispatch: RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter, - standalone_navigation_follow, + renderer_navigation_follow, pending_document_lifecycle_turn: None, post_response_document_lifecycle: None, vm: None, @@ -116,15 +116,15 @@ mod navigation_dispatch_tests { let handoff = crate::page_task_queue::RendererTopLevelNavigationHandoff::new(1); for succeeded in [false, true] { for mut state in [ - StandaloneNavigationFollowState::Idle, - StandaloneNavigationFollowState::Following { handoff }, - StandaloneNavigationFollowState::FailedWithPendingNavigation { handoff }, + RendererNavigationFollowState::Idle, + RendererNavigationFollowState::Following { handoff }, + RendererNavigationFollowState::FailedWithPendingNavigation { handoff }, ] { state.settle(None, succeeded); assert_eq!( state, - StandaloneNavigationFollowState::Idle, + RendererNavigationFollowState::Idle, "a committed navigation must settle to Idle after succeeded={succeeded}" ); } @@ -134,7 +134,7 @@ mod navigation_dispatch_tests { #[test] fn failed_phase_one_advance_returns_a_retiring_entry() { let advance = phase_one::classify_pending_phase_one_entry_advance( - phase_one_entry_shell_without_active_page_vm(StandaloneNavigationFollowState::Idle), + phase_one_entry_shell_without_active_page_vm(RendererNavigationFollowState::Idle), Err(anyhow!("resume failed")), ); @@ -152,7 +152,7 @@ mod navigation_dispatch_tests { #[test] fn successful_phase_one_advance_without_a_vm_is_retired_as_an_invariant_error() { let advance = phase_one::classify_pending_phase_one_entry_advance( - phase_one_entry_shell_without_active_page_vm(StandaloneNavigationFollowState::Idle), + phase_one_entry_shell_without_active_page_vm(RendererNavigationFollowState::Idle), Ok( LivePagePendingNavigationPhaseOneAdvance::TriggeredNavigation { stage: PageVmInitStage::DomContentLoaded, @@ -179,14 +179,14 @@ mod navigation_dispatch_tests { fn navigation_handoff_claim_rejects_stale_and_duplicate_requests() { let first = crate::page_task_queue::RendererTopLevelNavigationHandoff::new(1); let second = crate::page_task_queue::RendererTopLevelNavigationHandoff::new(2); - let mut state = StandaloneNavigationFollowState::Idle; + let mut state = RendererNavigationFollowState::Idle; assert!(!state.claim(Some(second), Some(first))); - assert_eq!(state, StandaloneNavigationFollowState::Idle); + assert_eq!(state, RendererNavigationFollowState::Idle); assert!(state.claim(Some(second), Some(second))); assert_eq!( state, - StandaloneNavigationFollowState::Following { handoff: second } + RendererNavigationFollowState::Following { handoff: second } ); assert!(!state.claim(Some(second), Some(second))); } @@ -195,18 +195,18 @@ mod navigation_dispatch_tests { fn failed_navigation_suppresses_only_the_same_request_identity() { let first = crate::page_task_queue::RendererTopLevelNavigationHandoff::new(1); let second = crate::page_task_queue::RendererTopLevelNavigationHandoff::new(2); - let mut state = StandaloneNavigationFollowState::Following { handoff: first }; + let mut state = RendererNavigationFollowState::Following { handoff: first }; state.settle(Some(first), false); assert_eq!( state, - StandaloneNavigationFollowState::FailedWithPendingNavigation { handoff: first } + RendererNavigationFollowState::FailedWithPendingNavigation { handoff: first } ); assert!(!state.claim(Some(first), Some(first))); assert!(state.claim(Some(second), Some(second))); assert_eq!( state, - StandaloneNavigationFollowState::Following { handoff: second } + RendererNavigationFollowState::Following { handoff: second } ); } diff --git a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs index 9c32522c19..fc48e143a5 100644 --- a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs +++ b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs @@ -1144,7 +1144,18 @@ impl PageVm { ); self.vm_mut() .restore_top_level_location_runtime_state(&initiator_url); - let replacement_html = self.vm_mut().eval_javascript_url_runtime_turn(&source)?; + if self.vm().script_execution_disabled() { + return Ok(PageVmFollowNavigationTurnOutcome::Completed); + } + let replacement_html = self + .vm_mut() + .eval_javascript_url_runtime_turn(&source, &initiator_url)?; + // Chromium suppresses a string completion when the script synchronously + // starts a normal navigation or submits a form. That newer navigation + // owns the target Document and must win over javascript: replacement. + if self.vm().has_pending_location_navigation() { + return Ok(PageVmFollowNavigationTurnOutcome::TriggeredNavigation { stage }); + } if let Some(replacement_html) = replacement_html { self.document_lifecycle.set_next_document_open_start_reason( RendererLifecycleStartReason::JavascriptDocumentReplacement, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/lifecycle.rs b/moli-renderer-v8/src/runtime/page_vm/tests/lifecycle.rs index 0ca4355b79..74bf7e85bb 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/lifecycle.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/lifecycle.rs @@ -867,6 +867,18 @@ fn page_diagnostics_snapshot_observes_but_does_not_drain_lifecycle_state() { ); } +async fn follow_pending_javascript_url_for_test( + page_vm: &mut PageVm, +) -> anyhow::Result { + let mut pending_document_lifecycle_turn = None; + page_vm + .follow_pending_location_navigation_one_turn_async( + &mut pending_document_lifecycle_turn, + PageVmInitStage::Load, + ) + .await +} + #[tokio::test] async fn javascript_location_navigation_executes_when_pending_navigation_is_followed() { run_page_vm_async_test(async move { @@ -878,7 +890,6 @@ async fn javascript_location_navigation_executes_when_pending_navigation_is_foll let (outcome_is_completed, log, href, network_records) = local_executor .run(async move { let mut page_vm = page_vm; - let mut pending_document_lifecycle_turn = None; page_vm.vm_mut().eval( r#" globalThis.__events = []; @@ -892,12 +903,7 @@ globalThis.__events.push('after setter'); "javascript: location should remain pending until the owner follows it" ); - let outcome = page_vm - .follow_pending_location_navigation_one_turn_async( - &mut pending_document_lifecycle_turn, - PageVmInitStage::Load, - ) - .await?; + let outcome = follow_pending_javascript_url_for_test(&mut page_vm).await?; let log = page_vm .vm_mut() .eval("JSON.stringify(globalThis.__events)")?; @@ -929,7 +935,240 @@ globalThis.__events.push('after setter'); } #[tokio::test] -async fn javascript_location_assignment_keeps_href_and_drops_pending_when_not_followed() { +async fn javascript_location_navigation_exception_is_reported_without_failing_navigation() { + run_page_vm_async_test(async move { + let page_vm = test_page_vm_with_document_url( + Url::parse("https://javascript-location-exception.test/start.html").unwrap(), + ); + let local_executor = page_vm.local_executor.clone(); + + let (completed, error_count, href) = local_executor + .run(async move { + let mut page_vm = page_vm; + page_vm.vm_mut().eval( + r#" +globalThis.__javascriptUrlErrorCount = 0; +window.addEventListener("error", event => { + globalThis.__javascriptUrlErrorCount += 1; + event.preventDefault(); +}); +location.href = "javascript:throw new Error('expected javascript URL failure')"; +"queued" +"#, + )?; + + let outcome = follow_pending_javascript_url_for_test(&mut page_vm).await?; + let error_count = page_vm + .vm_mut() + .eval("String(globalThis.__javascriptUrlErrorCount)")?; + let href = page_vm.vm_mut().eval("location.href")?; + Ok::<_, anyhow::Error>(( + matches!( + outcome, + crate::runtime::PageVmFollowNavigationTurnOutcome::Completed + ), + error_count, + href, + )) + }) + .await + .expect("javascript: exception should not fail the navigation task"); + + assert!(completed); + assert_eq!(error_count, "1"); + assert_eq!( + href, + "https://javascript-location-exception.test/start.html" + ); + }) + .await; +} + +#[tokio::test] +async fn javascript_location_navigation_obeys_trusted_types_pre_navigation_check() { + run_page_vm_async_test(async move { + let page_vm = test_page_vm_with_document_url( + Url::parse("https://javascript-location-trusted-types.test/start.html").unwrap(), + ); + let local_executor = page_vm.local_executor.clone(); + + let ran = local_executor + .run(async move { + let mut page_vm = page_vm; + page_vm.vm_mut().set_response_content_security_policies(&[ + "require-trusted-types-for 'script'".to_owned(), + ]); + page_vm.vm_mut().eval( + r#" +globalThis.__blockedJavascriptUrlRan = false; +location.href = "javascript:globalThis.__blockedJavascriptUrlRan = true"; +"queued" +"#, + )?; + + let outcome = follow_pending_javascript_url_for_test(&mut page_vm).await?; + assert!(matches!( + outcome, + crate::runtime::PageVmFollowNavigationTurnOutcome::Completed + )); + page_vm + .vm_mut() + .eval("String(globalThis.__blockedJavascriptUrlRan)") + }) + .await + .expect("Trusted Types should block the javascript: task without failing it"); + + assert_eq!(ran, "false"); + }) + .await; +} + +#[tokio::test] +async fn javascript_location_navigation_executes_default_policy_modified_source() { + run_page_vm_async_test(async move { + let page_vm = test_page_vm_with_document_url( + Url::parse("https://javascript-location-default-policy.test/start.html").unwrap(), + ); + let local_executor = page_vm.local_executor.clone(); + + let observed = local_executor + .run(async move { + let mut page_vm = page_vm; + page_vm + .vm_mut() + .set_response_content_security_policies(&[ + "require-trusted-types-for 'script'".to_owned(), + ]); + page_vm.vm_mut().eval( + r#" +globalThis.__originalJavascriptUrlRan = false; +globalThis.__modifiedJavascriptUrlRan = false; +globalThis.__javascriptUrlPolicyCalls = []; +trustedTypes.createPolicy("default", { + createScript(value, type, sink) { + globalThis.__javascriptUrlPolicyCalls.push([value, type, sink]); + return value.replace("__originalJavascriptUrlRan", "__modifiedJavascriptUrlRan"); + } +}); +location.href = "javascript:globalThis.__originalJavascriptUrlRan = true"; +"queued" +"#, + )?; + + let outcome = follow_pending_javascript_url_for_test(&mut page_vm).await?; + assert!(matches!( + outcome, + crate::runtime::PageVmFollowNavigationTurnOutcome::Completed + )); + page_vm.vm_mut().eval( + r#"JSON.stringify({ + original: globalThis.__originalJavascriptUrlRan, + modified: globalThis.__modifiedJavascriptUrlRan, + calls: globalThis.__javascriptUrlPolicyCalls +})"#, + ) + }) + .await + .expect("Trusted Types default policy should rewrite javascript: source"); + + assert_eq!( + observed, + r#"{"original":false,"modified":true,"calls":[["globalThis.__originalJavascriptUrlRan = true","TrustedScript","Location href"]]}"# + ); + }) + .await; +} + +#[tokio::test] +async fn queued_top_level_javascript_url_does_not_execute_after_scripting_is_disabled() { + run_page_vm_async_test(async move { + let page_vm = test_page_vm_with_document_url( + Url::parse("https://javascript-location-disabled.test/start.html").unwrap(), + ); + let local_executor = page_vm.local_executor.clone(); + + let (completed, ran) = local_executor + .run(async move { + let mut page_vm = page_vm; + page_vm.vm_mut().eval( + r#" +globalThis.__disabledJavascriptUrlRan = false; +location.href = "javascript:globalThis.__disabledJavascriptUrlRan = true"; +"queued" +"#, + )?; + page_vm.vm_mut().set_script_execution_disabled(true); + + let outcome = follow_pending_javascript_url_for_test(&mut page_vm).await?; + let ran = page_vm + .vm_mut() + .eval("String(globalThis.__disabledJavascriptUrlRan)")?; + Ok::<_, anyhow::Error>(( + matches!( + outcome, + crate::runtime::PageVmFollowNavigationTurnOutcome::Completed + ), + ran, + )) + }) + .await + .expect("disabled javascript: task should complete without execution"); + + assert!(completed); + assert_eq!(ran, "false"); + }) + .await; +} + +#[tokio::test] +async fn javascript_string_completion_does_not_replace_after_new_navigation_is_triggered() { + run_page_vm_async_test(async move { + let page_vm = test_page_vm_with_document_url( + Url::parse("https://javascript-location-precedence.test/start.html").unwrap(), + ); + let local_executor = page_vm.local_executor.clone(); + + let (triggered, pending_url, body_text) = local_executor + .run(async move { + let mut page_vm = page_vm; + page_vm.vm_mut().eval( + r#" +document.body.textContent = "original body"; +location.href = "javascript:(location.href = 'https://javascript-location-precedence.test/winner.html', 'replacement body')"; +"queued" +"#, + )?; + + let outcome = follow_pending_javascript_url_for_test(&mut page_vm).await?; + let pending = page_vm + .vm_mut() + .take_pending_location_navigation_with_seed() + .expect("javascript: execution should leave the newer navigation pending"); + let body_text = page_vm.vm_mut().eval("document.body.textContent")?; + Ok::<_, anyhow::Error>(( + matches!( + outcome, + crate::runtime::PageVmFollowNavigationTurnOutcome::TriggeredNavigation { .. } + ), + pending.url.to_string(), + body_text, + )) + }) + .await + .expect("newer navigation should suppress javascript: string replacement"); + + assert!(triggered); + assert_eq!( + pending_url, + "https://javascript-location-precedence.test/winner.html" + ); + assert_eq!(body_text, "original body"); + }) + .await; +} + +#[tokio::test] +async fn javascript_location_assignment_keeps_href_and_pending_when_not_delegated() { run_page_vm_async_test(async move { let page_vm = test_page_vm_with_document_url( Url::parse("https://javascript-location-ghost.test/start.html").unwrap(), @@ -960,8 +1199,8 @@ location.href "a javascript: pending navigation must never be published to the browser" ); assert!( - !page_vm.vm().has_pending_location_navigation(), - "dropping a javascript: pending navigation must clear the pending record" + page_vm.vm().has_pending_location_navigation(), + "browser publication must leave renderer-owned javascript: work pending" ); let href = page_vm.vm_mut().eval("location.href")?; assert_eq!( diff --git a/moli-renderer-v8/src/runtime/tests.rs b/moli-renderer-v8/src/runtime/tests.rs index 40e462417d..ee307d4d01 100644 --- a/moli-renderer-v8/src/runtime/tests.rs +++ b/moli-renderer-v8/src/runtime/tests.rs @@ -1873,7 +1873,7 @@ document.body.appendChild(frame); } #[tokio::test(flavor = "multi_thread")] -async fn javascript_location_assignment_does_not_ghost_location_href_on_delegate_pages() { +async fn javascript_location_assignment_executes_renderer_owned_task_on_delegate_pages() { let runtime = JsRuntime::initialize(); let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); @@ -1889,8 +1889,14 @@ async fn javascript_location_assignment_does_not_ghost_location_href_on_delegate let (set_reply, _) = page .run_async_command(RendererPageCommand::EvaluateExpression { - expression: r#"location.href = "javascript:document.title = 'LOC-RAN'"; "set""# - .to_owned(), + expression: r#" +globalThis.__javascriptLocationDone = new Promise(resolve => { + globalThis.__resolveJavascriptLocation = resolve; +}); +location.href = "javascript:document.title = 'LOC-RAN'; globalThis.__resolveJavascriptLocation('ran'); void 0"; +"set" +"# + .to_owned(), await_promise: false, }) .await @@ -1915,6 +1921,22 @@ async fn javascript_location_assignment_does_not_ghost_location_href_on_delegate "assigning a javascript: URL must not ghost location.href" ); + let (task_reply, _) = tokio::time::timeout( + Duration::from_secs(2), + page.run_async_command(RendererPageCommand::EvaluateExpression { + expression: "globalThis.__javascriptLocationDone".to_owned(), + await_promise: true, + }), + ) + .await + .expect("renderer-owned javascript: task should settle its promise") + .expect("javascript: task promise should evaluate"); + assert_eq!( + renderer_json_value(task_reply), + Some(serde_json::json!("ran")), + "the javascript: task must run after the assigning command" + ); + let (title_reply, _) = page .run_async_command(RendererPageCommand::EvaluateExpression { expression: "document.title".to_owned(), @@ -1924,8 +1946,7 @@ async fn javascript_location_assignment_does_not_ghost_location_href_on_delegate .expect("document.title readback should complete"); assert_eq!( renderer_json_value(title_reply), - Some(serde_json::json!("start")), - "a javascript: location assignment must not execute its script" + Some(serde_json::json!("LOC-RAN")) ); page.close_async() diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 72f94d612d..fcaa29c7a1 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -5568,11 +5568,6 @@ impl ScriptVm { &mut self, ) -> Option { if self.pending_location_navigation_scheme_is("javascript") { - let source_url = self.document_runtime.document_url().clone(); - self._context_host - .borrow_mut() - .clear_pending_location_navigation(); - self.restore_top_level_location_runtime_state(&source_url); return None; } let source_url = self.document_runtime.document_url().clone(); diff --git a/moli-renderer-v8/src/script_vm/eval_exec.rs b/moli-renderer-v8/src/script_vm/eval_exec.rs index fd186bedc9..7a25fe9452 100644 --- a/moli-renderer-v8/src/script_vm/eval_exec.rs +++ b/moli-renderer-v8/src/script_vm/eval_exec.rs @@ -652,20 +652,57 @@ impl ScriptVm { pub(crate) fn eval_javascript_url_runtime_turn( &mut self, source: &str, + base_url: &Url, ) -> Result> { + let source = source.to_owned(); + let base_url = base_url.clone(); let context_ptr: *const v8::Global = &self.page_default_context as *const _; - let completion = self - .execute_source_text_in_context_ptr_runtime_turn_with_base_url_and_current_window_error_report( - context_ptr, - source, - None, - None, - 0, - None, - true, - false, - SourceTextScriptCompletionMode::ValueTypeAware, - )?; + let provenance = CompiledStringProvenance::at_url(base_url.clone()); + let pending = PendingScriptTurn::new( + self.renderer_document_isolate + .with_renderer_document_isolate_mut::< + RawScriptExecutionResult>, + >(|isolate| { + let scope = pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let context = unsafe { v8::Local::new(scope, &*context_ptr) }; + let scope = &mut v8::ContextScope::new(scope, context); + let host_ptr = context_host_ptr_from_global_bridge(scope).ok_or_else(|| { + anyhow!("javascript URL target context has no Window host") + })?; + let Some(source) = unsafe { &mut *host_ptr } + .prepare_javascript_url_source_for_execution( + scope, + crate::native_bridge::OwnerDispatchScope::Top, + &source, + ) + else { + Self::perform_microtask_checkpoints(scope, Some(&base_url))?; + return Ok(None); + }; + execute_source_text_on_current_stack_with_completion( + scope, + &source, + Some(&provenance), + 0, + None, + true, + UncaughtScriptReportTarget::CurrentWindow, + SourceTextScriptCompletionMode::ValueTypeAware, + ) + .map(Some) + }), + ); + let completion = pending + .finish_with_style_drain(self, StyleInvalidationTurnExitBoundary::RuntimeEvaluate); + let completion = match completion { + Ok(Some(completion)) => completion, + Ok(None) => return Ok(None), + // A javascript: URL reports script exceptions to its Window, but + // the navigation itself completes without replacing the Document. + Err(RawScriptExecutionError::Exception { .. }) => return Ok(None), + Err(RawScriptExecutionError::Internal(error)) => return Err(error), + }; Ok(match completion { SourceTextScriptCompletion::String(value) => Some(value), SourceTextScriptCompletion::NonString => None, diff --git a/moli-renderer-v8/src/script_vm/frame_script_jobs.rs b/moli-renderer-v8/src/script_vm/frame_script_jobs.rs index 9c4be64e67..b552ac2f28 100644 --- a/moli-renderer-v8/src/script_vm/frame_script_jobs.rs +++ b/moli-renderer-v8/src/script_vm/frame_script_jobs.rs @@ -87,7 +87,7 @@ impl ScriptVm { } SourceTextScriptCompletion::NonString => Ok(FrameScriptCompletionValue::NonString), SourceTextScriptCompletion::Ignored => Err(anyhow!( - "source-text frame script job unexpectedly ignored completion" + "value-aware frame script execution unexpectedly ignored its completion" )), }) } @@ -122,7 +122,11 @@ impl ScriptVm { let Some(job) = self.prepare_inline_classic_frame_script_job(job)? else { return Ok(SourceTextScriptCompletion::Ignored); }; + let mut job = job; let realm_id = self.current_realm_id_for_frame_script_job(&job)?; + if !self.prepare_javascript_url_frame_script_job(realm_id, &mut job)? { + return Ok(SourceTextScriptCompletion::NonString); + } let is_source_text = matches!(&job.source, FrameScriptSource::SourceText(_)); let context_ptr = is_source_text .then(|| self.frame_realm_context_ptr(realm_id)) @@ -203,6 +207,40 @@ impl ScriptVm { result } + fn prepare_javascript_url_frame_script_job( + &mut self, + realm_id: FrameRealmId, + job: &mut FrameScriptJob, + ) -> Result { + if job.kind != crate::frame_owner_model::FrameScriptJobKind::JavascriptUrl { + return Ok(true); + } + let child_handle = self + ._context_host + .borrow() + .frame_owner_child_handle_for_script_job(job) + .ok_or_else(|| anyhow!("javascript URL frame job has no current child owner"))?; + #[cfg(not(test))] + let FrameScriptSource::SourceText(source) = &mut job.source; + #[cfg(test)] + let source = match &mut job.source { + FrameScriptSource::SourceText(source) => source, + FrameScriptSource::FunctionConstructor(_) => { + return Err(anyhow!("javascript URL frame job has no source text")); + } + }; + self.with_frame_realm_scope(realm_id, |scope, host_ptr| { + let owner = crate::native_bridge::OwnerDispatchScope::Child(child_handle); + let Some(checked_source) = unsafe { &mut *host_ptr } + .prepare_javascript_url_source_for_execution(scope, owner, source) + else { + return Ok(false); + }; + *source = checked_source; + Ok(true) + }) + } + fn prepare_inline_classic_frame_script_job( &mut self, mut job: FrameScriptJob, diff --git a/moli-renderer-v8/src/script_vm/security_policy.rs b/moli-renderer-v8/src/script_vm/security_policy.rs index 01dc5eba45..f67405ff83 100644 --- a/moli-renderer-v8/src/script_vm/security_policy.rs +++ b/moli-renderer-v8/src/script_vm/security_policy.rs @@ -151,6 +151,9 @@ pub(super) unsafe extern "C" fn string_code_generation_check_callback( let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { return true; }; + if crate::context_bootstrap::consume_internal_javascript_url_eval(scope) { + return true; + } let requires_trusted_types_for_script = unsafe { &*host_ptr }.requires_trusted_types_for_script(scope); let action = if requires_trusted_types_for_script { 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 88218fcae4..6d758ba26d 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 @@ -25035,6 +25035,162 @@ async fn lightweight_popup_javascript_url_navigation_runs_async_with_opener() { ); } +#[tokio::test] +async fn lightweight_popup_javascript_url_string_completion_replaces_document() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); + let mut vm = new_storage_test_vm_with_loader("https://example.com/base/page.html", &loader); + + let setup = vm + .eval( + r#" +(() => { + globalThis.__stringCompletionPopup = open(); + __stringCompletionPopup.location.href = + "javascript:'
replaced
'"; + return `${__stringCompletionPopup.location.href}|${__stringCompletionPopup.document.body.textContent}`; +})() +"#, + ) + .expect("popup javascript string completion setup should evaluate"); + + assert_eq!(setup, "about:blank|"); + vm.drain_pending_child_frame_work_for_test(); + assert!( + vm.run_next_due_timer_callback_for_test(&loader) + .await + .expect("popup javascript URL timer should run") + ); + vm.drain_pending_child_frame_work_for_test(); + assert_eq!( + vm.eval( + r##"JSON.stringify([ + __stringCompletionPopup.location.href, + __stringCompletionPopup.document.querySelector("#javascript-url-result").textContent, + __stringCompletionPopup.document.defaultView === __stringCompletionPopup, + __stringCompletionPopup.opener === window +])"##, + ) + .expect("popup javascript string completion should replace the document"), + r#"["about:blank","replaced",true,true]"# + ); +} + +#[tokio::test] +async fn loaded_lightweight_popup_can_replace_itself_from_javascript_url_string_completion() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); + let mut vm = + new_page_task_executor_test_vm_with_loader("https://example.com/base/page.html", &loader); + + assert_eq!( + vm.eval( + r#" +(() => { + const html = `