diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index fe88256178..0f08537d4e 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -2943,7 +2943,6 @@ html/semantics/scripting-1/the-script-element/json-module/json-module-service-wo html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-window-onerror-module.html html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-window-onerror.html html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-workerglobalscope-onerror.html -html/semantics/scripting-1/the-script-element/module/dynamic-import/alpha/base-url-worker-importScripts.html html/semantics/scripting-1/the-script-element/module/dynamic-import/code-cache-base-url.html html/semantics/scripting-1/the-script-element/module/inline-async-execorder.html html/semantics/scripting-1/the-script-element/moving-between-documents/ordering/delay-load-event-1.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 33047dfeb2..0925f1d534 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -6441,6 +6441,7 @@ html/semantics/scripting-1/the-script-element/module/currentScript-null.html html/semantics/scripting-1/the-script-element/module/custom-element-exception.html html/semantics/scripting-1/the-script-element/module/duplicated-imports-1.html html/semantics/scripting-1/the-script-element/module/duplicated-imports-2.html +html/semantics/scripting-1/the-script-element/module/dynamic-import/alpha/base-url-worker-importScripts.html html/semantics/scripting-1/the-script-element/module/dynamic-import/code-cache-nonce.html html/semantics/scripting-1/the-script-element/module/dynamic-import/delay-load-event.html html/semantics/scripting-1/the-script-element/module/dynamic-import/dynamic-imports-script-error.html diff --git a/moli-renderer-v8/src/content_security_policy.rs b/moli-renderer-v8/src/content_security_policy.rs index e68dd56dbf..7bdb8f2f5e 100644 --- a/moli-renderer-v8/src/content_security_policy.rs +++ b/moli-renderer-v8/src/content_security_policy.rs @@ -220,8 +220,8 @@ impl<'a> ContentSecurityPolicyViolationEventFields<'a> { disposition: violation.disposition, source_file: violation.source_file.as_str(), sample: violation.sample.as_str(), - line_number: 0, - column_number: 0, + line_number: violation.line_number, + column_number: violation.column_number, status_code: 0, } } @@ -478,7 +478,7 @@ pub(crate) fn content_security_policy_report_to_endpoints( pub(crate) fn content_security_policy_violation_report_body( fields: &ContentSecurityPolicyViolationEventFields<'_>, ) -> String { - json!({ + let mut report = json!({ "csp-report": { "document-uri": fields.document_uri, "referrer": fields.referrer, @@ -491,8 +491,14 @@ pub(crate) fn content_security_policy_violation_report_body( "status-code": fields.status_code, "script-sample": fields.sample, } - }) - .to_string() + }); + if fields.line_number != 0 { + report["csp-report"]["line-number"] = json!(fields.line_number); + } + if fields.column_number != 0 { + report["csp-report"]["column-number"] = json!(fields.column_number); + } + report.to_string() } pub(crate) fn content_security_policy_reporting_api_report_body( @@ -934,6 +940,34 @@ pub(crate) fn content_security_policy_source_file_for_report(source_file: &str) source_url.to_string() } +pub(crate) fn current_script_violation_location( + scope: &mut v8::PinScope<'_, '_>, +) -> Option<(String, i32, i32)> { + let stack = v8::StackTrace::current_stack_trace(scope, 1)?; + let frame = stack.get_frame(scope, 0)?; + // Imported worker scripts retain the originally requested URL separately + // from their final resource name and their (possibly muted) import base. + // Reporting the final URL could disclose a cross-origin redirect target. + let source_file = scope + .get_current_host_defined_options() + .and_then(|options| { + crate::util::script_request_url_from_host_defined_options(scope, options) + }) + .map(|url| url.to_string()) + .or_else(|| { + frame + .get_script_name_or_source_url(scope) + .map(|source| source.to_rust_string_lossy(scope)) + }) + .map(|source| content_security_policy_source_file_for_report(&source)) + .unwrap_or_default(); + let line_number = i32::try_from(frame.get_line_number()) + .unwrap_or_default() + .max(0); + let column_number = i32::try_from(frame.get_column()).unwrap_or_default().max(0); + Some((source_file, line_number, column_number)) +} + pub(crate) fn content_security_policy_trusted_types_sink_violation_with_disposition_and_reporting_endpoints( policies: &[String], protected_url: &Url, @@ -2958,6 +2992,35 @@ mod tests { ); } + #[test] + fn violation_report_formats_preserve_captured_script_locations() { + let mut violation = content_security_policy_url_violation_with_redirect_status( + &["connect-src 'none'".to_owned()], + &protected_url(), + &request_url("https://api.test/data.json"), + ContentSecurityPolicyResourceKind::WorkerConnect, + ContentSecurityPolicyRedirectStatus::NoRedirect, + ) + .unwrap(); + violation.source_file = "https://app.test/imported/script.js".into(); + violation.line_number = 12; + violation.column_number = 34; + let fields = ContentSecurityPolicyViolationEventFields::from_url_violation(&violation); + assert_eq!(fields.source_file, violation.source_file); + assert_eq!((fields.line_number, fields.column_number), (12, 34)); + let legacy: serde_json::Value = + serde_json::from_str(&content_security_policy_violation_report_body(&fields)).unwrap(); + assert_eq!(legacy["csp-report"]["source-file"], violation.source_file); + assert_eq!(legacy["csp-report"]["line-number"], 12); + assert_eq!(legacy["csp-report"]["column-number"], 34); + let reporting: serde_json::Value = + serde_json::from_str(&content_security_policy_reporting_api_report_body(&fields)) + .unwrap(); + assert_eq!(reporting[0]["body"]["sourceFile"], violation.source_file); + assert_eq!(reporting[0]["body"]["lineNumber"], 12); + assert_eq!(reporting[0]["body"]["columnNumber"], 34); + } + #[test] fn violation_report_request_uses_csp_fetch_security_modes() { let violation = content_security_policy_url_violation_with_redirect_status( diff --git a/moli-renderer-v8/src/custom_elements/construction_failure.rs b/moli-renderer-v8/src/custom_elements/construction_failure.rs index 9958b69cb2..d59e026733 100644 --- a/moli-renderer-v8/src/custom_elements/construction_failure.rs +++ b/moli-renderer-v8/src/custom_elements/construction_failure.rs @@ -46,6 +46,7 @@ pub(super) fn report_custom_element_construction_failure<'s>( }; if let Some(constructor) = constructor { let report = V8ExceptionReport { + muted_errors: false, summary: message, source: None, line: None, diff --git a/moli-renderer-v8/src/exception_reporting.rs b/moli-renderer-v8/src/exception_reporting.rs index df523e3de4..1d1c182493 100644 --- a/moli-renderer-v8/src/exception_reporting.rs +++ b/moli-renderer-v8/src/exception_reporting.rs @@ -13,6 +13,8 @@ const VALUE_DEBUG_SUMMARY_MAX_BYTES: usize = 160 * 4; const VALUE_DEBUG_OBJECT_SUMMARY_MAX_BYTES: usize = 240 * 4; pub(super) struct V8ExceptionReport { + /// Script-origin taint supplied by V8, retained until web-facing reporting. + pub(super) muted_errors: bool, pub(super) summary: String, pub(super) source: Option, pub(super) line: Option, @@ -155,6 +157,7 @@ pub(super) fn build_event_handler_exception_report<'s>( .or_else(|| exception.and_then(|exception| exception_stack_property(scope, exception))); let mut report = V8ExceptionReport { + muted_errors: message.is_some_and(|message| message.is_opaque()), summary, source, line, @@ -727,6 +730,7 @@ fn invoke_callback_with_report_inner<'s>( if scope.is_execution_terminating() { let _ = scope.rethrow(); captured_report = Some(V8ExceptionReport { + muted_errors: false, summary: format!("{callback_kind} `{callback_name}` was terminated"), source: None, line: None, @@ -757,6 +761,7 @@ fn invoke_callback_with_report_inner<'s>( let _ = callback_kind; returned_value.ok_or_else(|| { Box::new(captured_report.unwrap_or_else(|| V8ExceptionReport { + muted_errors: false, summary: format!("{callback_kind} `{callback_name}` threw"), source: None, line: None, diff --git a/moli-renderer-v8/src/host/timers.rs b/moli-renderer-v8/src/host/timers.rs index b90e43f197..1c05405519 100644 --- a/moli-renderer-v8/src/host/timers.rs +++ b/moli-renderer-v8/src/host/timers.rs @@ -1269,6 +1269,7 @@ fn run_window_timer_source( let mut scope = try_catch.init(); let Some(source_value) = v8_string(&scope, &source.source) else { return Err(Box::new(V8ExceptionReport { + muted_errors: false, summary: "failed to allocate timer source string".to_owned(), source: Some(source.provenance.source_url().to_string()), line: None, 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 2bb97cdbe5..22c8c48985 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 @@ -4,6 +4,7 @@ use crate::{ ContentSecurityPolicyNonUrlKind, ContentSecurityPolicyRedirectStatus, ContentSecurityPolicyReportingEndpoints, ContentSecurityPolicyScriptElementRequest, ContentSecurityPolicyViolationEventFields, TrustedTypesForScriptRequirements, + current_script_violation_location, }, context_bootstrap::CHILD_BROWSING_CONTEXT_HANDLE_SLOT, document_runtime::{ @@ -1304,22 +1305,3 @@ impl JsContextHost { Ok(()) } } - -fn current_script_violation_location( - scope: &mut v8::PinScope<'_, '_>, -) -> Option<(String, i32, i32)> { - let stack = v8::StackTrace::current_stack_trace(scope, 1)?; - let frame = stack.get_frame(scope, 0)?; - let source_file = frame - .get_script_name_or_source_url(scope) - .map(|source| source.to_rust_string_lossy(scope)) - .map(|source| { - crate::content_security_policy::content_security_policy_source_file_for_report(&source) - }) - .unwrap_or_default(); - let line_number = i32::try_from(frame.get_line_number()) - .unwrap_or_default() - .max(0); - let column_number = i32::try_from(frame.get_column()).unwrap_or_default().max(0); - Some((source_file, line_number, column_number)) -} diff --git a/moli-renderer-v8/src/script_vm/native_module.rs b/moli-renderer-v8/src/script_vm/native_module.rs index fd89d9218a..00925ebe50 100644 --- a/moli-renderer-v8/src/script_vm/native_module.rs +++ b/moli-renderer-v8/src/script_vm/native_module.rs @@ -5008,6 +5008,8 @@ fn create_module_script_origin<'s>( base_url, fetch_metadata.nonce(), fetch_metadata.parser_inserted, + false, + None, ) }); v8::ScriptOrigin::new( diff --git a/moli-renderer-v8/src/util.rs b/moli-renderer-v8/src/util.rs index 154626bac8..13b3048c70 100644 --- a/moli-renderer-v8/src/util.rs +++ b/moli-renderer-v8/src/util.rs @@ -282,6 +282,33 @@ fn script_host_defined_options_as_fixed_array<'s>( v8::Local::::try_from(host_defined_options).ok() } +pub(crate) fn script_muted_errors_from_host_defined_options( + scope: &mut v8::PinScope<'_, '_>, + host_defined_options: v8::Local<'_, v8::Data>, +) -> Option { + // Validate the shared marker before reading the optional provenance field. + script_base_url_from_host_defined_options(scope, host_defined_options)?; + let options = script_host_defined_options_as_fixed_array(host_defined_options)?; + if options.length() < 5 { + return None; + } + let value = v8::Local::::try_from(options.get(scope, 4)?).ok()?; + value.is_boolean().then_some(value.is_true()) +} + +pub(crate) fn script_request_url_from_host_defined_options( + scope: &mut v8::PinScope<'_, '_>, + host_defined_options: v8::Local<'_, v8::Data>, +) -> Option { + script_base_url_from_host_defined_options(scope, host_defined_options)?; + let options = script_host_defined_options_as_fixed_array(host_defined_options)?; + if options.length() < 6 { + return None; + } + let value = v8::Local::::try_from(options.get(scope, 5)?).ok()?; + Url::parse(&value.to_rust_string_lossy(scope)).ok() +} + pub(crate) fn script_base_url_from_continuation_data( scope: &mut v8::PinScope<'_, '_>, ) -> Option { @@ -302,7 +329,7 @@ pub(crate) fn script_host_defined_options_with_base_url_and_nonce<'s>( base_url: &Url, nonce: Option<&str>, ) -> Option> { - script_host_defined_options_with_fetch_metadata(scope, base_url, nonce, false) + script_host_defined_options_with_fetch_metadata(scope, base_url, nonce, false, false, None) } pub(crate) fn script_host_defined_options_with_fetch_metadata<'s>( @@ -310,6 +337,8 @@ pub(crate) fn script_host_defined_options_with_fetch_metadata<'s>( base_url: &Url, nonce: Option<&str>, parser_inserted: bool, + muted_errors: bool, + request_url: Option<&Url>, ) -> Option> { let marker = v8_string(scope, SCRIPT_BASE_URL_HOST_DEFINED_OPTIONS_MARKER)?; let value = v8_string(scope, base_url.as_str())?; @@ -322,11 +351,14 @@ pub(crate) fn script_host_defined_options_with_fetch_metadata<'s>( "not-parser-inserted" }, )?; - let options = v8::PrimitiveArray::new(scope, 4); + let request_url = v8_string(scope, request_url.map(Url::as_str).unwrap_or_default())?; + let options = v8::PrimitiveArray::new(scope, 6); options.set(scope, 0, marker.into()); options.set(scope, 1, value.into()); options.set(scope, 2, nonce.into()); options.set(scope, 3, parser_metadata.into()); + options.set(scope, 4, v8::Boolean::new(scope, muted_errors).into()); + options.set(scope, 5, request_url.into()); Some(options.into()) } @@ -634,8 +666,10 @@ mod tests { SCRIPT_BASE_URL_HOST_DEFINED_OPTIONS_MARKER, object_chain_contains, script_base_url_from_host_defined_options, script_host_defined_options_with_base_url_and_nonce, - script_host_defined_options_with_fetch_metadata, script_nonce_from_host_defined_options, - script_parser_inserted_from_host_defined_options, utf16_replace_units_range_lossy, + script_host_defined_options_with_fetch_metadata, + script_muted_errors_from_host_defined_options, script_nonce_from_host_defined_options, + script_parser_inserted_from_host_defined_options, + script_request_url_from_host_defined_options, utf16_replace_units_range_lossy, utf16_slice_lossy, utf16_split_units_lossy, utf16_units, walk_object_chain, }; use crate::ensure_v8_for_test as ensure_v8; @@ -714,10 +748,93 @@ mod tests { script_parser_inserted_from_host_defined_options(scope, options), Some(false) ); + assert_eq!( + script_muted_errors_from_host_defined_options(scope, options), + Some(false) + ); - let parser_options = - script_host_defined_options_with_fetch_metadata(scope, &base_url, Some("abc123"), true) - .expect("parser-inserted host-defined options should allocate"); + assert_eq!( + script_request_url_from_host_defined_options(scope, options), + None + ); + let request_url = Url::parse("https://request.test/redirect.js").unwrap(); + let muted = script_host_defined_options_with_fetch_metadata( + scope, + &base_url, + None, + false, + true, + Some(&request_url), + ) + .unwrap(); + assert_eq!( + script_muted_errors_from_host_defined_options(scope, muted), + Some(true) + ); + assert_eq!( + script_request_url_from_host_defined_options(scope, muted), + Some(request_url) + ); + for fields in [ + vec![ + SCRIPT_BASE_URL_HOST_DEFINED_OPTIONS_MARKER, + base_url.as_str(), + ], + vec![ + SCRIPT_BASE_URL_HOST_DEFINED_OPTIONS_MARKER, + base_url.as_str(), + "", + "not-parser-inserted", + "true", + ], + vec![ + "not-moli", + base_url.as_str(), + "", + "not-parser-inserted", + "true", + ], + ] { + let untrusted = primitive_host_defined_options(scope, &fields); + assert_eq!( + script_muted_errors_from_host_defined_options(scope, untrusted), + None + ); + assert_eq!( + script_request_url_from_host_defined_options(scope, untrusted), + None + ); + } + for (marker, request) in [ + (SCRIPT_BASE_URL_HOST_DEFINED_OPTIONS_MARKER, "not a URL"), + ("not-moli", "https://private.test/source.js"), + ] { + let untrusted = primitive_host_defined_options( + scope, + &[ + marker, + base_url.as_str(), + "", + "not-parser-inserted", + "", + request, + ], + ); + assert_eq!( + script_request_url_from_host_defined_options(scope, untrusted), + None + ); + } + + let parser_options = script_host_defined_options_with_fetch_metadata( + scope, + &base_url, + Some("abc123"), + true, + false, + None, + ) + .expect("parser-inserted host-defined options should allocate"); assert_eq!( script_parser_inserted_from_host_defined_options(scope, parser_options), Some(true) diff --git a/moli-renderer-v8/src/worker/global_scope/content_security_policy.rs b/moli-renderer-v8/src/worker/global_scope/content_security_policy.rs index 05becf4f2e..c71fbef268 100644 --- a/moli-renderer-v8/src/worker/global_scope/content_security_policy.rs +++ b/moli-renderer-v8/src/worker/global_scope/content_security_policy.rs @@ -13,7 +13,8 @@ use crate::content_security_policy::{ content_security_policy_trusted_types_sink_violation_with_disposition_and_reporting_endpoints, content_security_policy_url_violation_for_checked_url_with_redirect_status_disposition_and_reporting_endpoints, content_security_policy_url_violation_with_redirect_status_disposition_and_reporting_endpoints, - create_security_policy_violation_event, send_content_security_policy_reports, + create_security_policy_violation_event, current_script_violation_location, + send_content_security_policy_reports, }; use crate::context_bootstrap::dispatch_simple_event_target_event; use crate::network::loads::{ResourceLoadDisposition, ResourceLoadKind, ResourceLoadLease}; @@ -105,7 +106,14 @@ pub(super) fn dispatch_worker_trusted_types_sink_violation_event_for_state<'s>( &state_ref.content_security_reporting_endpoints, ) }; - if let Some(violation) = violation { + if let Some(mut violation) = violation { + if let Some((source_file, line_number, column_number)) = + current_script_violation_location(scope) + { + violation.source_file = source_file; + violation.line_number = line_number; + violation.column_number = column_number; + } dispatch_worker_content_security_policy_violation_event_for_state(scope, state, &violation); } } @@ -116,7 +124,7 @@ pub(super) fn allows_worker_trusted_type_policy_name_for_state<'s>( policy_name: &str, is_duplicate: bool, ) -> bool { - let (report_only_violation, enforced_violation) = { + let (mut report_only_violation, mut enforced_violation) = { let state_ref = state.borrow(); let Some(protected_url) = state_ref.current_script_url.as_ref() else { return true; @@ -142,6 +150,21 @@ pub(super) fn allows_worker_trusted_type_policy_name_for_state<'s>( (report_only_violation, enforced_violation) }; let allowed = enforced_violation.is_none(); + if allowed && report_only_violation.is_none() { + return true; + } + if let Some((source_file, line_number, column_number)) = + current_script_violation_location(scope) + { + for violation in [&mut report_only_violation, &mut enforced_violation] + .into_iter() + .flatten() + { + violation.source_file = source_file.clone(); + violation.line_number = line_number; + violation.column_number = column_number; + } + } // Match window policy creation reporting: enforce response policies are // modeled before report-only policies in the partitioned policy state. if let Some(violation) = enforced_violation { diff --git a/moli-renderer-v8/src/worker/global_scope/import_scripts.rs b/moli-renderer-v8/src/worker/global_scope/import_scripts.rs index a4babea416..b5a075d736 100644 --- a/moli-renderer-v8/src/worker/global_scope/import_scripts.rs +++ b/moli-renderer-v8/src/worker/global_scope/import_scripts.rs @@ -1,9 +1,14 @@ use super::*; +use crate::content_security_policy::{ + ContentSecurityPolicyRedirectStatus, ContentSecurityPolicyResourceKind, +}; +use moli_url::WebOrigin; pub(super) struct WorkerImportScriptSource { pub(super) final_url: Url, pub(super) source: String, pub(super) muted_errors: bool, + redirect_urls: Vec, resource: Option, } @@ -41,34 +46,13 @@ pub(super) fn materialize_worker_import_source( state: &Rc>, script_url: &Url, ) -> Result { - if let Some(violation) = { - let state = state.borrow(); - state.current_script_url.as_ref().and_then(|protected_url| { - worker_content_security_policy_report_only_violation( - &state, - protected_url, - script_url, - crate::content_security_policy::ContentSecurityPolicyResourceKind::WorkerScript, - ) - }) - } { - dispatch_worker_content_security_policy_violation_event_for_state(scope, state, &violation); - } - if let Some(violation) = { - let state = state.borrow(); - state.current_script_url.as_ref().and_then(|protected_url| { - worker_content_security_policy_violation( - &state, - protected_url, - script_url, - crate::content_security_policy::ContentSecurityPolicyResourceKind::WorkerScript, - ) - }) - } { - dispatch_worker_content_security_policy_violation_event_for_state(scope, state, &violation); - let message = worker_content_security_policy_error_message(&violation, "importScripts"); - return Err(WorkerImportScriptError::network(message)); - } + check_import_script_csp( + scope, + state, + script_url, + script_url, + ContentSecurityPolicyRedirectStatus::NoRedirect, + )?; match script_url.scheme() { "data" => { let source = @@ -85,6 +69,7 @@ pub(super) fn materialize_worker_import_source( final_url: script_url.clone(), source, muted_errors: false, + redirect_urls: Vec::new(), resource: None, }) } @@ -101,32 +86,43 @@ pub(super) fn materialize_worker_import_source( final_url: script_url.clone(), source: body, muted_errors: false, + redirect_urls: Vec::new(), resource: None, }) } "http" | "https" => { - let (loader, initiator_url, referrer_policy, network_partition_key) = { + let (loader, initiator_url, referrer_policy, network_partition_key, policy_context) = { let state = state.borrow(); ( state.loader.clone(), state.current_script_url.clone(), state.referrer_policy.clone(), state.network_partition_key.clone(), + state.policy_context, ) }; - fetch_worker_import_source_blocking( + let source = fetch_worker_import_source_blocking( loader, script_url.clone(), initiator_url, referrer_policy, network_partition_key, + policy_context, ) - .inspect(|source| { - if let Some(resource) = source.resource.clone() { - report_service_worker_imported_script_loaded(state, resource); - } - }) - .map_err(WorkerImportScriptError::network) + .map_err(WorkerImportScriptError::network)?; + for checked_url in &source.redirect_urls { + check_import_script_csp( + scope, + state, + script_url, + checked_url, + ContentSecurityPolicyRedirectStatus::FollowedRedirect, + )?; + } + if let Some(resource) = source.resource.clone() { + report_service_worker_imported_script_loaded(state, resource); + } + Ok(source) } scheme => Err(WorkerImportScriptError::network(format!( "Failed to execute 'importScripts': URL scheme `{scheme}` is not allowed." @@ -134,6 +130,39 @@ pub(super) fn materialize_worker_import_source( } } +fn check_import_script_csp( + scope: &mut v8::PinScope<'_, '_>, + state: &Rc>, + request_url: &Url, + checked_url: &Url, + redirect_status: ContentSecurityPolicyRedirectStatus, +) -> Result<(), WorkerImportScriptError> { + let (report, enforce) = { + let state = state.borrow(); + let Some(protected_url) = state.current_script_url.as_ref() else { + return Ok(()); + }; + ( + worker_content_security_policy_report_only_violation_for_checked_url_with_redirect_status( + &state, protected_url, checked_url, request_url, ContentSecurityPolicyResourceKind::WorkerScript, redirect_status, + ), + worker_content_security_policy_violation_for_checked_url_with_redirect_status( + &state, protected_url, checked_url, request_url, ContentSecurityPolicyResourceKind::WorkerScript, redirect_status, + ), + ) + }; + if let Some(violation) = report { + dispatch_worker_content_security_policy_violation_event_for_state(scope, state, &violation); + } + if let Some(violation) = enforce { + dispatch_worker_content_security_policy_violation_event_for_state(scope, state, &violation); + return Err(WorkerImportScriptError::network( + worker_content_security_policy_error_message(&violation, "importScripts"), + )); + } + Ok(()) +} + fn ensure_worker_import_script_mime_acceptable( script_url: &Url, mime_type: &str, @@ -150,11 +179,18 @@ pub(super) fn fetch_worker_import_source_blocking( initiator_url: Option, referrer_policy: Option, network_partition_key: Option, + policy_context: crate::types::SubresourcePolicyContext, ) -> Result { let request_url = script_url.clone(); let mut request = moli_fetch::Request::new("GET", script_url.as_str(), None, vec![]) .map_err(|error| error.to_string())? .with_page_network_policy() + .with_request_mode(RequestMode::NoCors) + .with_credentials_mode(RequestCredentialsMode::SameOrigin) + .with_script_fetch_metadata(moli_fetch::ScriptFetchRequestMetadata { + document_referrer_policy: referrer_policy, + ..moli_fetch::ScriptFetchRequestMetadata::default() + }) .with_network_partition_key(network_partition_key); let request_initiator_url = initiator_url.clone(); if let Some(ref initiator_url) = request_initiator_url { @@ -162,12 +198,6 @@ pub(super) fn fetch_worker_import_source_blocking( .with_initiator_url(initiator_url) .with_request_origin(moli_url::WebOrigin::from_url(initiator_url)); } - if let Some(referrer_policy) = referrer_policy { - request = request.with_script_fetch_metadata(moli_fetch::ScriptFetchRequestMetadata { - document_referrer_policy: Some(referrer_policy), - ..moli_fetch::ScriptFetchRequestMetadata::default() - }); - } let response_started_at = Instant::now(); let cancel_handle = FetchCancelHandle::new(); let load = loader @@ -187,22 +217,47 @@ pub(super) fn fetch_worker_import_source_blocking( .elapsed() .as_millis() .min(u64::MAX as u128) as u64; - moli_fetch::ensure_http_status_success(response.final_url.as_str(), response.status, false) - .map_err(|error| error.to_string())?; - crate::worker::ensure_worker_script_mime_acceptable( - &response.final_url, - &response.headers, - response.body_bytes(), - )?; - if request_initiator_url.as_ref().is_some_and(|initiator_url| { - matches!(initiator_url.scheme(), "http" | "https") - && !moli_url::same_origin(initiator_url, &response.final_url) - }) { - return Err(format!( - "Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at '{}' failed to load.", - response.final_url - )); - } + // Classic imported scripts use no-cors, unlike the worker's top-level + // same-origin fetch and module CORS fetches. Any cross-origin response in + // the URL chain taints the result, even if it redirects back to the worker. + let muted_errors = request_initiator_url.as_ref().is_some_and(|initiator_url| { + !moli_url::same_origin(initiator_url, &request_url) + || !moli_url::same_origin(initiator_url, &response.final_url) + || response.redirect_chain.iter().any(|redirect| { + !moli_url::same_origin(initiator_url, &redirect.from_url) + || !moli_url::same_origin(initiator_url, &redirect.to_url) + }) + }); + let response_validation = (|| { + moli_fetch::ensure_http_status_success(response.final_url.as_str(), response.status, false) + .map_err(|error| error.to_string())?; + crate::worker::ensure_worker_script_mime_acceptable( + &response.final_url, + &response.headers, + response.body_bytes(), + )?; + if let Some(initiator_url) = &request_initiator_url { + validate_fetch_response_security_policy_for_origin( + initiator_url, + &WebOrigin::from_url(initiator_url), + &response.final_url, + &response.headers, + RequestMode::NoCors, + RequestCredentialsMode::SameOrigin, + policy_context, + )?; + } + Ok::<_, String>(()) + })(); + response_validation.map_err(|error| { + if muted_errors { + format!( + "Failed to execute 'importScripts': The script at '{script_url}' failed to load." + ) + } else { + error + } + })?; let (head, body, body_bytes) = response.into_parts(); let resource = crate::worker::WorkerScriptResource::from_response_parts( request_url, @@ -213,7 +268,12 @@ pub(super) fn fetch_worker_import_source_blocking( Ok(WorkerImportScriptSource { final_url: head.final_url, source: body, - muted_errors: false, + muted_errors, + redirect_urls: head + .redirect_chain + .into_iter() + .map(|redirect| redirect.to_url) + .collect(), resource: Some(resource), }) } @@ -242,74 +302,95 @@ fn report_service_worker_imported_script_loaded( pub(super) fn evaluate_worker_script( scope: &mut v8::PinScope<'_, '_>, - state: Rc>, + request_url: &Url, script_url: &Url, script_source: &str, muted_errors: bool, ) -> Result<(), WorkerImportScriptError> { - let previous_url = { - let mut state = state.borrow_mut(); - state.current_script_url.replace(script_url.clone()) + let source = v8::String::new(scope, script_source).ok_or_else(|| { + WorkerImportScriptError::error( + scope, + format!("failed to allocate worker source for `{script_url}`"), + ) + })?; + let name = v8::String::new(scope, script_url.as_str()).expect("worker script origin"); + let sanitized_base = Url::parse("about:blank").expect("valid sanitized script base"); + let base_url = if muted_errors { + &sanitized_base + } else { + script_url }; - let outcome = (|| { - let source = v8::String::new(scope, script_source).ok_or_else(|| { - WorkerImportScriptError::error( - scope, - format!("failed to allocate worker source for `{script_url}`"), - ) - })?; - let origin = create_script_origin(scope, script_url.as_str()); - let try_catch = std::pin::pin!(v8::TryCatch::new(scope)); - let mut scope = try_catch.init(); - let Some(script) = v8::Script::compile(&scope, source, Some(&origin)) else { - if muted_errors { - return Err(WorkerImportScriptError::network(format!( - "Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at '{script_url}' failed to load." - ))); - } - let error = scope - .exception() - .map(|value| { - let message = scope.message(); - annotate_worker_exception_location(&mut scope, value, message); - WorkerImportScriptError::Exception(v8::Global::new(&scope, value)) - }) - .unwrap_or_else(|| { - WorkerImportScriptError::error( - &mut scope, - format!("failed to compile `{script_url}`"), - ) - }); - return Err(error); - }; - let _ = script.run(&scope); - if scope.has_caught() { - if muted_errors { - return Err(WorkerImportScriptError::network(format!( - "Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at '{script_url}' failed to load." - ))); - } - let error = scope - .exception() - .map(|value| { - let message = scope.message(); - annotate_worker_exception_location(&mut scope, value, message); - WorkerImportScriptError::Exception(v8::Global::new(&scope, value)) - }) - .unwrap_or_else(|| { - WorkerImportScriptError::error( - &mut scope, - format!("failed to execute `{script_url}`"), - ) - }); - return Err(error); + let host_defined_options = crate::util::script_host_defined_options_with_fetch_metadata( + scope, + base_url, + None, + false, + muted_errors, + Some(request_url), + ); + let origin = v8::ScriptOrigin::new( + scope, + name.into(), + 0, + 0, + false, + -1, + None, + muted_errors, + false, + false, + host_defined_options, + ); + let try_catch = std::pin::pin!(v8::TryCatch::new(scope)); + let mut scope = try_catch.init(); + let Some(script) = v8::Script::compile(&scope, source, Some(&origin)) else { + if muted_errors { + return Err(WorkerImportScriptError::network( + "Failed to execute 'importScripts': A cross-origin script failed to execute." + .to_owned(), + )); } - scope.perform_microtask_checkpoint(); - crate::context_bootstrap::run_end_of_microtask_checkpoint_tasks(&mut scope); - Ok(()) - })(); - state.borrow_mut().current_script_url = previous_url; - outcome + let error = scope + .exception() + .map(|value| { + let message = scope.message(); + annotate_worker_exception_location(&mut scope, value, message); + WorkerImportScriptError::Exception(v8::Global::new(&scope, value)) + }) + .unwrap_or_else(|| { + WorkerImportScriptError::error( + &mut scope, + format!("failed to compile `{script_url}`"), + ) + }); + return Err(error); + }; + let _ = script.run(&scope); + if scope.has_caught() { + if muted_errors { + return Err(WorkerImportScriptError::network( + "Failed to execute 'importScripts': A cross-origin script failed to execute." + .to_owned(), + )); + } + let error = scope + .exception() + .map(|value| { + let message = scope.message(); + annotate_worker_exception_location(&mut scope, value, message); + WorkerImportScriptError::Exception(v8::Global::new(&scope, value)) + }) + .unwrap_or_else(|| { + WorkerImportScriptError::error( + &mut scope, + format!("failed to execute `{script_url}`"), + ) + }); + return Err(error); + } + scope.perform_microtask_checkpoint(); + crate::context_bootstrap::run_end_of_microtask_checkpoint_tasks(&mut scope); + Ok(()) } // ─── console ──────────────────────────────────────────────────────────────── diff --git a/moli-renderer-v8/src/worker/global_scope/mod.rs b/moli-renderer-v8/src/worker/global_scope/mod.rs index 5069c9935d..3edd7f5735 100644 --- a/moli-renderer-v8/src/worker/global_scope/mod.rs +++ b/moli-renderer-v8/src/worker/global_scope/mod.rs @@ -1500,7 +1500,8 @@ pub(crate) struct WorkerGlobalState { pub(super) global_kind: super::thread::WorkerGlobalKind, /// Whether this worker was constructed as a classic or module worker. pub(super) script_kind: super::thread::WorkerScriptKind, - /// Base URL used to resolve relative worker script fetches. + /// Worker global's URL and settings base, unchanged by importScripts(). + /// Imported scripts carry their separate import base in V8 ScriptOrigin. pub(super) current_script_url: Option, /// Referrer policy parsed from the top-level worker script response. pub(super) referrer_policy: Option, @@ -6853,6 +6854,7 @@ fn worker_import_scripts_callback<'s>( }); } for mut script in prepared { + let request_url = script.final_url.clone(); if script.source.is_none() { let import_source = match materialize_worker_import_source(scope, &state, &script.final_url) { @@ -6869,7 +6871,7 @@ fn worker_import_scripts_callback<'s>( let source = script.source.as_deref().unwrap_or_default(); if let Err(error) = evaluate_worker_script( scope, - state.clone(), + &request_url, &script.final_url, source, script.muted_errors, @@ -7035,23 +7037,6 @@ fn call_original_worker_console_method<'s>( // ─── timers (minimal stubs) ───────────────────────────────────────────────── -fn create_script_origin<'s>(scope: &mut v8::PinScope<'s, '_>, url: &str) -> v8::ScriptOrigin<'s> { - let name = v8::String::new(scope, url).expect("worker script origin"); - v8::ScriptOrigin::new( - scope, - name.into(), - 0, - 0, - false, - -1, - None, - false, - false, - false, - None, - ) -} - fn set_prop( scope: &mut v8::PinScope<'_, '_>, obj: v8::Local<'_, v8::Object>, diff --git a/moli-renderer-v8/src/worker/module_runtime.rs b/moli-renderer-v8/src/worker/module_runtime.rs index 86ad935c64..69a5fba44d 100644 --- a/moli-renderer-v8/src/worker/module_runtime.rs +++ b/moli-renderer-v8/src/worker/module_runtime.rs @@ -2649,6 +2649,7 @@ fn worker_bootstrap_error( v8::String::new(scope, summary).map(|message| v8::Exception::syntax_error(scope, message)); ( V8ExceptionReport { + muted_errors: false, summary: summary.to_owned(), source: Some(script_url.to_owned()), line: Some(1), @@ -2675,6 +2676,7 @@ fn worker_bootstrap_value_error( .unwrap_or_else(|| "module worker evaluation failed".to_owned()); ( V8ExceptionReport { + muted_errors: false, summary, source: Some(script_url.to_owned()), line: Some(1), @@ -3060,13 +3062,14 @@ fn worker_import_attributes_key( pub(super) fn worker_dynamic_import_callback<'s, 'i>( scope: &mut v8::PinScope<'s, 'i>, - _host_defined_options: v8::Local<'s, v8::Data>, + host_defined_options: v8::Local<'s, v8::Data>, resource_name: v8::Local<'s, v8::Value>, specifier: v8::Local<'s, v8::String>, import_attributes: v8::Local<'s, v8::FixedArray>, ) -> Option> { queue_worker_dynamic_import( scope, + host_defined_options, resource_name, specifier, import_attributes, @@ -3076,6 +3079,7 @@ pub(super) fn worker_dynamic_import_callback<'s, 'i>( fn queue_worker_dynamic_import<'s>( scope: &mut v8::PinScope<'s, '_>, + host_defined_options: v8::Local<'s, v8::Data>, resource_name: v8::Local<'s, v8::Value>, specifier: v8::Local<'s, v8::String>, import_attributes: v8::Local<'s, v8::FixedArray>, @@ -3108,7 +3112,9 @@ fn queue_worker_dynamic_import<'s>( ); return Some(promise); } - let base_url = resource_url; + let base_url = + crate::util::script_base_url_from_host_defined_options(scope, host_defined_options) + .or(resource_url); let Some(base_url) = base_url else { reject_worker_dynamic_import_resolver( scope, @@ -3162,6 +3168,7 @@ pub(super) fn worker_dynamic_import_with_phase_callback<'s, 'i>( } queue_worker_dynamic_import( scope, + host_defined_options, resource_name, specifier, import_attributes, diff --git a/moli-renderer-v8/src/worker/thread/dispatch.rs b/moli-renderer-v8/src/worker/thread/dispatch.rs index e66db6bc53..718a83fb90 100644 --- a/moli-renderer-v8/src/worker/thread/dispatch.rs +++ b/moli-renderer-v8/src/worker/thread/dispatch.rs @@ -566,6 +566,15 @@ unsafe extern "C" fn worker_promise_reject_callback(message: v8::PromiseRejectMe match message.get_event() { v8::PromiseRejectEvent::PromiseRejectWithNoHandler => { + if scope + .get_current_host_defined_options() + .is_some_and(|options| { + crate::util::script_muted_errors_from_host_defined_options(scope, options) + == Some(true) + }) + { + return; + } let promise = message.get_promise(); let mut pending = pending_unhandled_rejections.borrow_mut(); if pending.iter().any(|rejection| { @@ -4341,7 +4350,20 @@ pub(super) fn dispatch_worker_exception_with_phase_and_source<'s>( parent_tx: &mpsc::UnboundedSender, script_url: &str, ) -> bool { - apply_worker_exception_location_overrides(scope, &mut report, exception); + let exception = if report.muted_errors { + report.summary = "Script error.".to_owned(); + report.source = Some(String::new()); + report.line = Some(0); + report.column = Some(0); + report.source_line = None; + report.stack = None; + report.callback_context = None; + report.exception = None; + Some(v8::null(scope).into()) + } else { + apply_worker_exception_location_overrides(scope, &mut report, exception); + exception + }; let handled = dispatch_worker_error_event(scope, global, &report, exception, parent_tx, script_url); if !handled { diff --git a/moli-renderer-v8/src/worker/thread/mod.rs b/moli-renderer-v8/src/worker/thread/mod.rs index 826378acb1..834892a0c1 100644 --- a/moli-renderer-v8/src/worker/thread/mod.rs +++ b/moli-renderer-v8/src/worker/thread/mod.rs @@ -2697,6 +2697,7 @@ async fn worker_main( if let Some(error) = result.unhandled_error { let global = ctx.global(scope); let report = V8ExceptionReport { + muted_errors: false, summary: error.message, source: Some(error.filename), line: Some(error.lineno as usize), @@ -3513,6 +3514,7 @@ fn worker_bootstrap_error( v8::String::new(scope, summary).map(|message| v8::Exception::syntax_error(scope, message)); ( V8ExceptionReport { + muted_errors: false, summary: summary.to_owned(), source: Some(script_url.to_owned()), line: Some(1), diff --git a/moli-renderer-v8/src/worker/thread/tests/imported_scripts.rs b/moli-renderer-v8/src/worker/thread/tests/imported_scripts.rs new file mode 100644 index 0000000000..a394c955bc --- /dev/null +++ b/moli-renderer-v8/src/worker/thread/tests/imported_scripts.rs @@ -0,0 +1,480 @@ +use super::*; + +#[tokio::test] +async fn worker_importscripts_trusted_types_reports_keep_script_and_document_locations_separate() { + ensure_v8(); + let source = "self.violatePolicy = () => {\n try { trustedTypes.createPolicy('forbidden'); } catch {}\n};\nself.violateSink = () => {\n try { setTimeout('blocked code'); } catch {}\n};\nviolatePolicy();\nviolateSink();"; + let (base_url, server) = spawn_path_response_http_server(vec![( + "/imported/violations.js", + "HTTP/1.1 200 OK", + "text/javascript", + source.into(), + Duration::ZERO, + )]) + .await; + let worker_url = format!("{base_url}/worker/main.js"); + let options = WorkerSpawnOptions::new( + r#" + const violations = []; + addEventListener('securitypolicyviolation', event => violations.push([ + event.effectiveDirective, event.documentURI, event.sourceFile, + event.lineNumber, event.columnNumber > 0 + ])); + const setup = trustedTypes.createPolicy('bootstrap', { createScriptURL: s => s }); + importScripts(setup.createScriptURL('../imported/violations.js')); + setTimeout(() => { + violatePolicy(); violateSink(); + postMessage(violations); close(); + }, 0); + "# + .into(), + worker_url.clone(), + ) + .with_content_security_policies(vec![ + "require-trusted-types-for 'script'; trusted-types bootstrap".into(), + ]); + let mut handle = spawn_test_worker_with_options(options); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + let actual: serde_json::Value = serde_json::from_str(&expect_post_json(message)).unwrap(); + let script_url = format!("{base_url}/imported/violations.js"); + assert_eq!( + actual, + serde_json::json!([ + ["trusted-types", worker_url, script_url, 2, true], + ["require-trusted-types-for", worker_url, script_url, 5, true], + ["trusted-types", worker_url, script_url, 2, true], + ["require-trusted-types-for", worker_url, script_url, 5, true], + ]) + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn worker_importscripts_trusted_types_reports_do_not_expose_redirect_targets() { + ensure_v8(); + let (foreign_url, foreign_server) = spawn_path_response_http_server(vec![( + "/private-user/violations.js?credential=hidden", + "HTTP/1.1 200 OK", + "text/javascript", + "setTimeout(() => {\n try { trustedTypes.createPolicy('forbidden'); } catch {}\n postMessage(violations); close();\n}, 0);".into(), + Duration::ZERO, + )]) + .await; + let redirect = format!( + "HTTP/1.1 302 Found\r\nLocation: {foreign_url}/private-user/violations.js?credential=hidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let (base_url, server) = spawn_raw_path_response_http_server(vec![( + "/worker/redirect.js", + redirect, + Duration::ZERO, + )]) + .await; + let worker_url = format!("{base_url}/worker/main.js"); + let options = WorkerSpawnOptions::new( + r#" + const violations = []; + addEventListener('securitypolicyviolation', event => violations.push([ + event.disposition, event.documentURI, event.sourceFile, + event.lineNumber, event.columnNumber > 0 + ])); + importScripts('./redirect.js'); + "# + .into(), + worker_url.clone(), + ) + .with_content_security_policies(vec!["trusted-types 'none'".into()]) + .with_content_security_report_only_policies(vec!["trusted-types 'none'".into()]); + let mut handle = spawn_test_worker_with_options(options); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + let actual: serde_json::Value = serde_json::from_str(&expect_post_json(message)).unwrap(); + let request_url = format!("{base_url}/worker/redirect.js"); + assert_eq!( + actual, + serde_json::json!([ + ["enforce", worker_url, request_url, 2, true], + ["report", worker_url, request_url, 2, true], + ]) + ); + server.await.unwrap(); + foreign_server.await.unwrap(); +} + +#[tokio::test] +async fn worker_importscripts_cross_origin_respects_corp_and_coep() { + ensure_v8(); + for (require_corp, corp, expected) in [ + (false, "same-origin", r#"["NetworkError",false]"#), + (true, "", r#"["NetworkError",false]"#), + (true, "cross-origin", r#"["ok",true]"#), + ] { + let body = "self.loaded = true;"; + let corp_header = if corp.is_empty() { + String::new() + } else { + format!("Cross-Origin-Resource-Policy: {corp}\r\n") + }; + let response = format!( + "HTTP/1.1 200 OK\r\n{corp_header}Content-Type: text/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let (foreign_url, server) = + spawn_raw_path_response_http_server(vec![("/foreign.js", response, Duration::ZERO)]) + .await; + let foreign = serde_json::to_string(&format!("{foreign_url}/foreign.js")).unwrap(); + let policy_context = crate::types::SubresourcePolicyContext { + cross_origin_embedder_policy: if require_corp { + crate::cross_origin_isolation::CrossOriginEmbedderPolicy::RequireCorp + } else { + crate::cross_origin_isolation::CrossOriginEmbedderPolicy::None + }, + ..Default::default() + }; + let options = WorkerSpawnOptions::new( + format!( + r#" + let outcome = 'ok'; + try {{ importScripts({foreign}); }} catch (error) {{ outcome = error.name; }} + postMessage([outcome, self.loaded === true]); close(); + "# + ), + "http://127.0.0.1/worker/main.js".into(), + ) + .with_policy_context(policy_context); + let mut handle = spawn_test_worker_with_options(options); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + assert_eq!( + expect_post_json(message), + expected, + "require_corp={require_corp}, corp={corp}" + ); + server.await.unwrap(); + } +} + +#[tokio::test] +async fn worker_importscripts_redirects_check_csp_and_ignore_redirected_paths() { + ensure_v8(); + for (report_only, allow_foreign, expected) in [ + (false, false, r#"["NetworkError",false,["enforce"]]"#), + (true, false, r#"["ok",true,["report"]]"#), + (false, true, r#"["ok",true,[]]"#), + ] { + let (foreign_url, foreign_server) = spawn_path_response_http_server(vec![( + "/redirect-target/foreign.js", + "HTTP/1.1 200 OK", + "text/javascript", + "self.loaded = true;".into(), + Duration::ZERO, + )]) + .await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {foreign_url}/redirect-target/foreign.js\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let (worker_url, server) = spawn_raw_path_response_http_server(vec![( + "/worker/redirect.js", + response, + Duration::ZERO, + )]) + .await; + let policy = if allow_foreign { + format!("script-src 'self' {foreign_url}/only-before-redirect/") + } else { + "script-src 'self'".to_owned() + }; + let mut options = WorkerSpawnOptions::new( + r#" + const violations = []; + addEventListener('securitypolicyviolation', event => violations.push(event.disposition)); + let outcome = 'ok'; + try { importScripts('./redirect.js'); } catch (error) { outcome = error.name; } + postMessage([outcome, self.loaded === true, violations]); close(); + "#.into(), format!("{worker_url}/worker/main.js"), + ); + options = if report_only { + options.with_content_security_report_only_policies(vec![policy]) + } else { + options.with_content_security_policies(vec![policy]) + }; + let mut handle = spawn_test_worker_with_options(options); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + assert_eq!( + expect_post_json(message), + expected, + "report={report_only}, allow={allow_foreign}" + ); + server.await.unwrap(); + foreign_server.await.unwrap(); + } +} + +#[tokio::test] +async fn worker_importscripts_cross_origin_does_not_bypass_module_cors() { + ensure_v8(); + let (foreign_url, server) = spawn_path_response_http_server(vec![ + ("/foreign.js", "HTTP/1.1 200 OK", "text/javascript", + "self.result = import(self.foreignModule).then(() => 'unexpected', error => error.name);".into(), Duration::ZERO), + ("/foreign-module.js", "HTTP/1.1 200 OK", "text/javascript", + "export const value = 'private module';".into(), Duration::ZERO), + ]).await; + let script = serde_json::to_string(&format!("{foreign_url}/foreign.js")).unwrap(); + let module = serde_json::to_string(&format!("{foreign_url}/foreign-module.js")).unwrap(); + let mut handle = spawn_worker_with_request_client( + format!( + r#" + self.foreignModule = {module}; + try {{ + importScripts({script}); + result.then(value => {{ postMessage(value); close(); }}); + }} catch (error) {{ postMessage('importScripts:' + error.name); close(); }} + "# + ), + "http://127.0.0.1/worker/main.js".into(), + worker_test_request_client(), + ); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + assert_eq!(expect_post_json(message), r#""TypeError""#); + server.await.unwrap(); +} + +#[tokio::test] +async fn worker_importscripts_cross_origin_redirect_chain_stays_muted_after_returning() { + ensure_v8(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let worker_url = format!("http://{}", listener.local_addr().unwrap()); + let redirect = format!( + "HTTP/1.1 302 Found\r\nLocation: {worker_url}/worker/creator.js\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let (foreign_url, foreign_server) = + spawn_raw_path_response_http_server(vec![("/return.js", redirect, Duration::ZERO)]).await; + let server = tokio::spawn(async move { + let mut paths = Vec::new(); + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request_head(&mut stream).await.unwrap(); + let path = request + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + paths.push(path.to_owned()); + let (status, extra, body) = match path { + "/worker/redirect.js" => ( + "302 Found", + format!("Location: {foreign_url}/return.js\r\n"), + "", + ), + "/worker/creator.js" => ( + "200 OK", + String::new(), + "self.result = import('./leaf.js').then(() => 'unexpected', error => error.name);", + ), + "/worker/leaf.js" => ( + "200 OK", + String::new(), + "export const value = 'unexpected';", + ), + "/done" => ("200 OK", String::new(), "done"), + _ => panic!("unexpected importScripts request {path}"), + }; + let response = format!( + "HTTP/1.1 {status}\r\n{extra}Content-Type: text/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + if path == "/done" { + return paths; + } + } + }); + let mut handle = spawn_worker_with_request_client( + r#" + try { + importScripts('./redirect.js'); + result.then(async value => { await fetch('/done'); postMessage(value); close(); }); + } catch (error) { postMessage('importScripts:' + error.name); close(); } + "# + .into(), + format!("{worker_url}/worker/main.js"), + worker_test_request_client(), + ); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + assert_eq!(expect_post_json(message), r#""TypeError""#); + assert_eq!( + server.await.unwrap(), + ["/worker/redirect.js", "/worker/creator.js", "/done"] + ); + foreign_server.await.unwrap(); +} + +#[tokio::test] +async fn worker_importscripts_keeps_settings_base_separate_from_dynamic_import_base() { + ensure_v8(); + let (base_url, server) = spawn_path_response_http_server(vec![ + ( + "/imported/creator.js", + "HTTP/1.1 200 OK", + "text/javascript", + "importScripts('./nested.js'); self.importLater = () => import('./leaf.js');".into(), + Duration::ZERO, + ), + ( + "/worker/nested.js", + "HTTP/1.1 200 OK", + "text/javascript", + "self.nested = 'worker';".into(), + Duration::ZERO, + ), + ( + "/imported/leaf.js", + "HTTP/1.1 200 OK", + "text/javascript", + "export const value = 'imported';".into(), + Duration::ZERO, + ), + ]) + .await; + let mut handle = spawn_worker_with_request_client( + r#" + try { + importScripts('../imported/creator.js'); + importLater().then(module => { + postMessage({nested, value: module.value}); close(); + }, error => { postMessage({error: error.name}); close(); }); + } catch (error) { postMessage({error: error.name}); close(); } + "# + .into(), + format!("{base_url}/worker/main.js"), + worker_test_request_client(), + ); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + assert_eq!( + expect_post_json(message), + r#"{"nested":"worker","value":"imported"}"# + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn worker_importscripts_cross_origin_sanitizes_import_base_but_not_settings_origin() { + ensure_v8(); + let (worker_url, worker_server) = spawn_path_response_http_server(vec![ + ( + "/worker/nested.js", + "HTTP/1.1 200 OK", + "text/javascript", + "self.nested = 'worker';".into(), + Duration::ZERO, + ), + ( + "/worker/leaf.js", + "HTTP/1.1 200 OK", + "text/javascript", + "export const value = 'worker-module';".into(), + Duration::ZERO, + ), + ]) + .await; + let absolute = serde_json::to_string(&format!("{worker_url}/worker/leaf.js")).unwrap(); + let source = format!( + r#" + importScripts('./nested.js'); + self.relativeImport = import('./leaf.js').then(() => 'unexpected', error => error.name); + self.absoluteImport = import({absolute}).then(module => module.value); + self.importLater = () => import('./later.js').then(() => 'unexpected', error => error.name); + "# + ); + let (foreign_url, foreign_server) = spawn_path_response_http_server(vec![( + "/foreign/creator.js", + "HTTP/1.1 200 OK", + "text/javascript", + source, + Duration::ZERO, + )]) + .await; + let foreign = serde_json::to_string(&format!("{foreign_url}/foreign/creator.js")).unwrap(); + let mut handle = spawn_worker_with_request_client( + format!( + r#" + try {{ + importScripts({foreign}); + Promise.all([relativeImport, absoluteImport, importLater()]).then(values => {{ + postMessage({{nested, values}}); close(); + }}, error => {{ postMessage({{error: error.name}}); close(); }}); + }} catch (error) {{ postMessage({{error: error.name}}); close(); }} + "# + ), + format!("{worker_url}/worker/main.js"), + worker_test_request_client(), + ); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + assert_eq!( + expect_post_json(message), + r#"{"nested":"worker","values":["TypeError","worker-module","TypeError"]}"# + ); + foreign_server.await.unwrap(); + worker_server.await.unwrap(); +} + +#[tokio::test] +async fn worker_importscripts_cross_origin_async_errors_are_muted() { + ensure_v8(); + let (foreign_url, server) = spawn_path_response_http_server(vec![( + "/foreign.js", + "HTTP/1.1 200 OK", + "text/javascript", + "setTimeout(() => { throw new Error('private message'); }, 0);".into(), + Duration::ZERO, + )]) + .await; + let foreign = serde_json::to_string(&format!("{foreign_url}/foreign.js")).unwrap(); + let mut handle = spawn_worker_with_request_client( + format!( + r#" + addEventListener('error', event => {{ + postMessage({{message: event.message, filename: event.filename, + line: event.lineno, column: event.colno, errorIsNull: event.error === null}}); + event.preventDefault(); close(); + }}); + importScripts({foreign}); + "# + ), + "http://127.0.0.1/worker/main.js".into(), + worker_test_request_client(), + ); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + assert_eq!( + expect_post_json(message), + r#"{"message":"Script error.","filename":"","line":0,"column":0,"errorIsNull":true}"# + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn worker_importscripts_cross_origin_rejections_are_not_reported() { + ensure_v8(); + let (foreign_url, server) = spawn_path_response_http_server(vec![ + ("/foreign.js", "HTTP/1.1 200 OK", "text/javascript", + "Promise.reject('private immediate'); setTimeout(() => Promise.reject('private timer'), 0);".into(), Duration::ZERO), + ]).await; + let foreign = serde_json::to_string(&format!("{foreign_url}/foreign.js")).unwrap(); + let mut handle = spawn_worker_with_request_client( + format!( + r#" + const reasons = []; + addEventListener('unhandledrejection', event => {{ + reasons.push(event.reason); event.preventDefault(); + }}); + try {{ importScripts({foreign}); }} catch (error) {{ reasons.push(error.name); }} + Promise.reject('public'); + setTimeout(() => {{ postMessage(reasons); close(); }}, 0); + "# + ), + "http://127.0.0.1/worker/main.js".into(), + worker_test_request_client(), + ); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + assert_eq!(expect_post_json(message), r#"["public"]"#); + server.await.unwrap(); +} diff --git a/moli-renderer-v8/src/worker/thread/tests/mod.rs b/moli-renderer-v8/src/worker/thread/tests/mod.rs index 4864d21651..ac68dd7610 100644 --- a/moli-renderer-v8/src/worker/thread/tests/mod.rs +++ b/moli-renderer-v8/src/worker/thread/tests/mod.rs @@ -1518,6 +1518,7 @@ fn service_worker_storage_apis_use_explicit_registration_storage_key() { // ─── Basic tests ──────────────────────────────────────────────────── mod cors_redirects; +mod imported_scripts; mod lazy_storage; mod lifecycle; mod modules; diff --git a/moli-renderer-v8/src/worker/thread/tests/modules.rs b/moli-renderer-v8/src/worker/thread/tests/modules.rs index 065adac0a5..386a3899ed 100644 --- a/moli-renderer-v8/src/worker/thread/tests/modules.rs +++ b/moli-renderer-v8/src/worker/thread/tests/modules.rs @@ -4957,7 +4957,7 @@ async fn worker_importscripts_cross_origin_failures_throw_network_error() { "/throw.js", "HTTP/1.1 200 OK", "application/javascript", - "globalThis.__crossOriginLoaded = true;".to_owned(), + "globalThis.__crossOriginLoaded = true; throw new Error('private message');".to_owned(), Duration::ZERO, ), ]) @@ -5003,7 +5003,7 @@ async fn worker_importscripts_cross_origin_failures_throw_network_error() { .expect("channel closed"); assert_eq!( expect_post_json(msg), - r#"[{"name":"NetworkError","domException":true,"loaded":false},{"name":"NetworkError","domException":true,"loaded":false}]"# + r#"[{"name":"NetworkError","domException":true,"loaded":false},{"name":"NetworkError","domException":true,"loaded":true}]"# ); script_server .await @@ -5011,7 +5011,7 @@ async fn worker_importscripts_cross_origin_failures_throw_network_error() { } #[tokio::test] -async fn worker_importscripts_redirect_to_cross_origin_failure_throws_network_error() { +async fn worker_importscripts_redirect_to_cross_origin_script_executes() { ensure_v8(); let (cross_origin_base_url, script_server) = spawn_path_response_http_server(vec![( "/throw.js", @@ -5037,7 +5037,7 @@ async fn worker_importscripts_redirect_to_cross_origin_failure_throws_network_er try { importScripts("./redirect-throw.js"); postMessage({ - name: "unexpected", + name: "ok", domException: false, loaded: globalThis.__redirectedCrossOriginLoaded === true, }); @@ -5061,7 +5061,7 @@ async fn worker_importscripts_redirect_to_cross_origin_failure_throws_network_er .expect("channel closed"); assert_eq!( expect_post_json(msg), - r#"{"name":"NetworkError","domException":true,"loaded":false}"# + r#"{"name":"ok","domException":false,"loaded":true}"# ); redirect_server .await diff --git a/moli-renderer-v8/src/worker/thread/tests/network.rs b/moli-renderer-v8/src/worker/thread/tests/network.rs index 4bae4c6960..96e290bbab 100644 --- a/moli-renderer-v8/src/worker/thread/tests/network.rs +++ b/moli-renderer-v8/src/worker/thread/tests/network.rs @@ -6176,7 +6176,7 @@ async fn worker_classic_websocket_offline_reports_network_failure() { } #[tokio::test] -async fn worker_importscripts_websocket_resolves_against_imported_script_url() { +async fn worker_importscripts_websocket_resolves_against_worker_settings_url() { ensure_v8(); let imported_script = r#" const events = []; @@ -6199,7 +6199,7 @@ async fn worker_importscripts_websocket_resolves_against_imported_script_url() { )]) .await; let websocket_base_url = base_url.replacen("http://", "ws://", 1); - let expected_url = format!("{websocket_base_url}/worker/imported/blocked/imported-ws"); + let expected_url = format!("{websocket_base_url}/worker/blocked/imported-ws"); let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("worker importScripts loader"); let mut handle = spawn_worker_with_request_client_and_blocked_url_patterns( @@ -6209,7 +6209,7 @@ async fn worker_importscripts_websocket_resolves_against_imported_script_url() { .into(), format!("{base_url}/worker/main.js"), loader, - vec![format!("{websocket_base_url}/worker/imported/blocked/*")], + vec![format!("{websocket_base_url}/worker/blocked/*")], ); let network = timeout(TIMEOUT, handle.recv()) diff --git a/moli-wpt-compat/fixtures/wpt/manifest.toml b/moli-wpt-compat/fixtures/wpt/manifest.toml index 06890a3124..e9fc849f0f 100644 --- a/moli-wpt-compat/fixtures/wpt/manifest.toml +++ b/moli-wpt-compat/fixtures/wpt/manifest.toml @@ -11463,7 +11463,7 @@ wait_until = "load" timeout_ms = 5000 suite = "smoke" tags = ["worker"] -notes = "Manual smoke port for dedicated worker importScripts rejecting direct cross-origin URLs and same-origin redirects that land on cross-origin targets with NetworkError. Same-origin importScripts injection and blob-url importScripts are covered separately." +notes = "Manual smoke port for dedicated worker importScripts executing direct and redirected cross-origin scripts, retaining their side effects while replacing their thrown exceptions with NetworkError. Same-origin importScripts injection and blob-url importScripts are covered separately." [[test]] id = "worker-importscripts-blob-basic" diff --git a/moli-wpt-compat/fixtures/wpt/ported/worker/worker-importscripts-network-error-basic.html b/moli-wpt-compat/fixtures/wpt/ported/worker/worker-importscripts-network-error-basic.html index 4688bb605d..2a96c13f33 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/worker/worker-importscripts-network-error-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/worker/worker-importscripts-network-error-basic.html @@ -58,9 +58,9 @@ promise_test(async function () { directResult.domException, "direct cross-origin importScripts should throw a DOMException", ); - assert_false( + assert_true( directResult.loaded, - "direct cross-origin importScripts should not execute the target script", + "direct cross-origin script executes before its exception is replaced with NetworkError", ); assert_equals( @@ -72,12 +72,12 @@ promise_test(async function () { redirectResult.domException, "redirected cross-origin importScripts should throw a DOMException", ); - assert_false( + assert_true( redirectResult.loaded, - "redirected cross-origin importScripts should not execute the target script", + "redirected cross-origin script executes before its exception is replaced with NetworkError", ); } finally { worker.terminate(); } -}, "Dedicated workers reject cross-origin importScripts loads, including redirected targets"); +}, "Dedicated workers execute cross-origin imported scripts and mute their exceptions, including redirected targets");