From bb4ec4a97190bfcdc5b4cd9a0137718d14751ced Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 15 Sep 2026 19:23:26 +0800 Subject: [PATCH] fix(csp): correct importScripts policy checks and events Use script-src-elem with script-src/default-src fallbacks for imported classic scripts. Queue Worker CSP violations after the current task while preserving the importer's sanitized source location and synchronous NetworkError behavior. Cover late listeners, microtask ordering, directive precedence, imported callsites, and report-only behavior in dedicated and shared workers. --- .../src/content_security_policy.rs | 7 +- .../src/worker/global_scope/import_scripts.rs | 23 +- .../worker/thread/tests/imported_scripts.rs | 29 +- .../src/worker/thread/tests/modules.rs | 257 +++++++++++++----- 4 files changed, 232 insertions(+), 84 deletions(-) diff --git a/moli-renderer-v8/src/content_security_policy.rs b/moli-renderer-v8/src/content_security_policy.rs index bb7890089..3cdfe8f7c 100644 --- a/moli-renderer-v8/src/content_security_policy.rs +++ b/moli-renderer-v8/src/content_security_policy.rs @@ -2047,11 +2047,12 @@ impl ContentSecurityPolicyResourceKind { Self::DocumentImage => IMG_SRC, Self::DocumentManifest => MANIFEST_SRC, Self::DocumentMedia => MEDIA_SRC, - Self::DocumentScriptElement | Self::WorkerDynamicModuleImport => SCRIPT_SRC_ELEM, + Self::DocumentScriptElement | Self::WorkerDynamicModuleImport | Self::WorkerScript => { + SCRIPT_SRC_ELEM + } Self::DocumentStyleElement => STYLE_SRC_ELEM, Self::WorkerConstructor | Self::WorkerStaticModuleImport => WORKER_SRC, Self::WorkerConnect => CONNECT_SRC, - Self::WorkerScript => SCRIPT_SRC, } } @@ -2068,7 +2069,7 @@ impl ContentSecurityPolicyResourceKind { Self::WorkerConstructor => &[WORKER_SRC, CHILD_SRC, SCRIPT_SRC, DEFAULT_SRC], Self::WorkerConnect => &[CONNECT_SRC, DEFAULT_SRC], Self::WorkerDynamicModuleImport => &[SCRIPT_SRC_ELEM, SCRIPT_SRC, DEFAULT_SRC], - Self::WorkerScript => &[SCRIPT_SRC, DEFAULT_SRC], + Self::WorkerScript => &[SCRIPT_SRC_ELEM, SCRIPT_SRC, DEFAULT_SRC], Self::WorkerStaticModuleImport => &[WORKER_SRC, CHILD_SRC, SCRIPT_SRC, DEFAULT_SRC], } } 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 1db93858f..59b6b6937 100644 --- a/moli-renderer-v8/src/worker/global_scope/import_scripts.rs +++ b/moli-renderer-v8/src/worker/global_scope/import_scripts.rs @@ -137,12 +137,13 @@ fn check_import_script_csp( checked_url: &Url, redirect_status: ContentSecurityPolicyRedirectStatus, ) -> Result<(), WorkerImportScriptError> { - let (report, enforce) = { + let (wake_tx, mut report, mut enforce) = { let state = state.borrow(); let Some(protected_url) = state.current_script_url.as_ref() else { return Ok(()); }; ( + state.worker_wake_tx.clone(), worker_content_security_policy_report_only_violation_for_checked_url_with_redirect_status( &state, protected_url, checked_url, request_url, ContentSecurityPolicyResourceKind::WorkerScript, redirect_status, ), @@ -151,14 +152,26 @@ fn check_import_script_csp( ), ) }; + // importScripts throws synchronously, but CSP events run in a later task. + // Capture the caller now, before its stack is lost or imported code runs. + if report.is_some() || enforce.is_some() { + let location = + crate::content_security_policy::ContentSecurityPolicySourceLocation::capture(scope); + for violation in [&mut report, &mut enforce].into_iter().flatten() { + location.apply_to(violation); + } + } if let Some(violation) = report { - dispatch_worker_content_security_policy_violation_event_for_state(scope, state, &violation); + let _ = wake_tx.send(WorkerMessage::DispatchContentSecurityPolicyViolation( + Box::new(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"), + let message = worker_content_security_policy_error_message(&violation, "importScripts"); + let _ = wake_tx.send(WorkerMessage::DispatchContentSecurityPolicyViolation( + Box::new(violation), )); + return Err(WorkerImportScriptError::network(message)); } Ok(()) } diff --git a/moli-renderer-v8/src/worker/thread/tests/imported_scripts.rs b/moli-renderer-v8/src/worker/thread/tests/imported_scripts.rs index 7113d0bb5..7c24be7cf 100644 --- a/moli-renderer-v8/src/worker/thread/tests/imported_scripts.rs +++ b/moli-renderer-v8/src/worker/thread/tests/imported_scripts.rs @@ -161,9 +161,9 @@ async fn worker_importscripts_cross_origin_respects_corp_and_coep() { 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,[]]"#), + (false, false, r#"["NetworkError",false,["enforce"],true]"#), + (true, false, r#"["ok",true,["report"],true]"#), + (false, true, r#"["ok",true,[],true]"#), ] { let (foreign_url, foreign_server) = spawn_path_response_http_server(vec![( "/redirect-target/foreign.js", @@ -190,11 +190,28 @@ async fn worker_importscripts_redirects_check_csp_and_ignore_redirected_paths() 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"), + let microtaskRan = false; + queueMicrotask(() => microtaskRan = true); + const finish = () => { + postMessage([outcome, self.loaded === true, violations, microtaskRan]); + close(); + }; + if (EXPECT_VIOLATION) { + addEventListener('securitypolicyviolation', event => { + violations.push(event.disposition); + finish(); + }); + } else { + queueMicrotask(finish); + } + "# + .replace( + "EXPECT_VIOLATION", + if allow_foreign { "false" } else { "true" }, + ), + format!("{worker_url}/worker/main.js"), ); options = if report_only { options.with_content_security_report_only_policies(vec![policy]) diff --git a/moli-renderer-v8/src/worker/thread/tests/modules.rs b/moli-renderer-v8/src/worker/thread/tests/modules.rs index 27c700cb5..6d363bcda 100644 --- a/moli-renderer-v8/src/worker/thread/tests/modules.rs +++ b/moli-renderer-v8/src/worker/thread/tests/modules.rs @@ -154,32 +154,42 @@ async fn worker_importscripts_obeys_response_csp_script_src() { WorkerSpawnOptions::new( r#" const events = []; - addEventListener("securitypolicyviolation", event => { - events.push({ - type: event.type, - effectiveDirective: event.effectiveDirective, - violatedDirective: event.violatedDirective, - blockedURI: event.blockedURI, - documentURI: event.documentURI, - originalPolicy: event.originalPolicy, - disposition: event.disposition, - instance: event instanceof SecurityPolicyViolationEvent - }); - }); + let name; + addEventListener("securitypolicyviolation", event => events.push(event)); try { importScripts("data:text/javascript,globalThis.__ran=true"); postMessage("unexpected"); } catch (error) { - postMessage({ - events, - name: error && error.name, - ran: globalThis.__ran === true, - }); + name = error.name; } - close(); + const eventsAtReturn = events.length; + let eventsAtMicrotask; + queueMicrotask(() => eventsAtMicrotask = events.length); + addEventListener("securitypolicyviolation", event => { + postMessage({ + event: { + type: event.type, + effectiveDirective: event.effectiveDirective, + violatedDirective: event.violatedDirective, + blockedURI: event.blockedURI, + documentURI: event.documentURI, + originalPolicy: event.originalPolicy, + disposition: event.disposition, + instance: event instanceof SecurityPolicyViolationEvent, + sourceFile: event.sourceFile, + lineNumber: event.lineNumber, + columnNumber: event.columnNumber, + }, + name, + ran: globalThis.__ran === true, + eventsAtReturn, + eventsAtMicrotask, + }); + close(); + }); "# .into(), - "https://app.test/worker/main.js".into(), + "https://app.test/worker/main.js?secret=1".into(), ) .with_content_security_policies(vec!["script-src 'none'".to_owned()]), ); @@ -190,7 +200,105 @@ async fn worker_importscripts_obeys_response_csp_script_src() { .expect("channel closed"); assert_eq!( expect_post_json(msg), - r#"{"events":[{"type":"securitypolicyviolation","effectiveDirective":"script-src","violatedDirective":"script-src","blockedURI":"data","documentURI":"https://app.test/worker/main.js","originalPolicy":"script-src 'none'","disposition":"enforce","instance":true}],"name":"NetworkError","ran":false}"# + r#"{"event":{"type":"securitypolicyviolation","effectiveDirective":"script-src-elem","violatedDirective":"script-src-elem","blockedURI":"data","documentURI":"https://app.test/worker/main.js?secret=1","originalPolicy":"script-src 'none'","disposition":"enforce","instance":true,"sourceFile":"https://app.test/worker/main.js","lineNumber":6,"columnNumber":17},"name":"NetworkError","ran":false,"eventsAtReturn":0,"eventsAtMicrotask":0}"# + ); +} + +#[tokio::test] +async fn worker_importscripts_csp_uses_script_src_elem_and_its_fallbacks() { + ensure_v8(); + for (policies, blocked) in [ + (vec!["script-src 'none'; script-src-elem data:"], false), + (vec!["script-src data:; script-src-elem 'none'"], true), + (vec!["default-src 'none'; script-src data:"], false), + (vec!["default-src data:; script-src 'none'"], true), + (vec!["default-src data:"], false), + (vec!["default-src 'none'"], true), + (vec!["worker-src 'none'"], false), + (vec!["script-src-elem data:", "script-src 'none'"], true), + ] { + let mut handle = spawn_test_worker_with_options( + WorkerSpawnOptions::new( + r#" + let name; + try { + importScripts("data:text/javascript,globalThis.__ran=true"); + } catch (error) { + name = error.name; + } + if (name === undefined) { + postMessage({ran: globalThis.__ran === true}); + close(); + } else { + addEventListener("securitypolicyviolation", event => { + postMessage({ + ran: globalThis.__ran === true, + name, + directive: event.effectiveDirective, + disposition: event.disposition, + }); + close(); + }); + } + "# + .into(), + "https://app.test/worker/main.js".into(), + ) + .with_content_security_policies(policies.iter().map(|p| (*p).to_owned()).collect()), + ); + let msg = timeout(TIMEOUT, handle.recv()) + .await + .expect("timed out") + .expect("channel closed"); + assert_eq!( + expect_post_json(msg), + if blocked { + r#"{"ran":false,"name":"NetworkError","directive":"script-src-elem","disposition":"enforce"}"# + } else { + r#"{"ran":true}"# + }, + "{policies:?}", + ); + } +} + +#[tokio::test] +async fn worker_importscripts_csp_reports_the_imported_callsite() { + ensure_v8(); + let mut handle = spawn_test_worker_with_options( + WorkerSpawnOptions::new( + r#" + let name; + try { + importScripts("data:text/javascript," + encodeURIComponent( + "\n importScripts('https://blocked.test/script.js');" + )); + } catch (error) { + name = error.name; + } + addEventListener("securitypolicyviolation", event => { + postMessage({ + name, + blockedURI: event.blockedURI, + sourceFile: event.sourceFile, + lineNumber: event.lineNumber, + columnNumber: event.columnNumber, + }); + close(); + }); + "# + .into(), + "https://app.test/worker/main.js".into(), + ) + .with_content_security_policies(vec!["script-src data:".into()]), + ); + let msg = timeout(TIMEOUT, handle.recv()) + .await + .expect("timed out") + .expect("channel closed"); + assert_eq!( + expect_post_json(msg), + r#"{"name":"NetworkError","blockedURI":"https://blocked.test/script.js","sourceFile":"data","lineNumber":2,"columnNumber":3}"# ); } @@ -206,27 +314,27 @@ async fn worker_csp_violation_event_survives_mutated_event_globals() { writable: false, configurable: true }); - const events = []; - addEventListener("securitypolicyviolation", event => { - events.push({ - type: event.type, - blockedURI: event.blockedURI, - effectiveDirective: event.effectiveDirective, - disposition: event.disposition, - instance: event instanceof SecurityPolicyViolationEvent - }); - }); + let name; try { importScripts("data:text/javascript,globalThis.__ran=true"); postMessage("unexpected"); } catch (error) { + name = error.name; + } + addEventListener("securitypolicyviolation", event => { postMessage({ - events, - name: error && error.name, + event: { + type: event.type, + blockedURI: event.blockedURI, + effectiveDirective: event.effectiveDirective, + disposition: event.disposition, + instance: event instanceof SecurityPolicyViolationEvent + }, + name, ran: globalThis.__ran === true, }); - } - close(); + close(); + }); "# .into(), "https://app.test/worker/main.js".into(), @@ -240,7 +348,7 @@ async fn worker_csp_violation_event_survives_mutated_event_globals() { .expect("channel closed"); assert_eq!( expect_post_json(msg), - r#"{"events":[{"type":"securitypolicyviolation","blockedURI":"data","effectiveDirective":"script-src","disposition":"enforce","instance":true}],"name":"NetworkError","ran":false}"# + r#"{"event":{"type":"securitypolicyviolation","blockedURI":"data","effectiveDirective":"script-src-elem","disposition":"enforce","instance":true},"name":"NetworkError","ran":false}"# ); } @@ -257,24 +365,27 @@ async fn shared_worker_importscripts_csp_block_dispatches_securitypolicyviolatio WorkerSpawnOptions::new( r#" onconnect = () => { - let matched = false; + let name; + try { + importScripts("data:text/javascript,globalThis.__ran=true"); + } catch (error) { + name = error.name; + } + let microtaskRan = false; + queueMicrotask(() => microtaskRan = true); addEventListener("securitypolicyviolation", event => { - matched = event.type === "securitypolicyviolation" && - event.effectiveDirective === "script-src" && - event.violatedDirective === "script-src" && + const matched = event.type === "securitypolicyviolation" && + event.effectiveDirective === "script-src-elem" && + event.violatedDirective === "script-src-elem" && event.blockedURI === "data" && event.documentURI === "https://app.test/shared-worker.js" && event.originalPolicy === "script-src 'none'" && event.disposition === "enforce" && event instanceof SecurityPolicyViolationEvent; - }); - try { - importScripts("data:text/javascript,globalThis.__ran=true"); - } catch (_) { - if (matched && globalThis.__ran !== true) { + if (matched && name === "NetworkError" && microtaskRan && globalThis.__ran !== true) { close(); } - } + }); }; "# .into(), @@ -309,24 +420,29 @@ async fn worker_importscripts_report_only_csp_dispatches_without_blocking() { WorkerSpawnOptions::new( r#" const events = []; - addEventListener("securitypolicyviolation", event => { - events.push({ - type: event.type, - effectiveDirective: event.effectiveDirective, - violatedDirective: event.violatedDirective, - blockedURI: event.blockedURI, - documentURI: event.documentURI, - originalPolicy: event.originalPolicy, - disposition: event.disposition, - instance: event instanceof SecurityPolicyViolationEvent - }); - }); + addEventListener("securitypolicyviolation", event => events.push(event)); importScripts("data:text/javascript,globalThis.__ran=true"); - postMessage({ - events, - ran: globalThis.__ran === true, + const eventsAtReturn = events.length; + let eventsAtMicrotask; + queueMicrotask(() => eventsAtMicrotask = events.length); + addEventListener("securitypolicyviolation", event => { + postMessage({ + event: { + type: event.type, + effectiveDirective: event.effectiveDirective, + violatedDirective: event.violatedDirective, + blockedURI: event.blockedURI, + documentURI: event.documentURI, + originalPolicy: event.originalPolicy, + disposition: event.disposition, + instance: event instanceof SecurityPolicyViolationEvent + }, + ran: globalThis.__ran === true, + eventsAtReturn, + eventsAtMicrotask, + }); + close(); }); - close(); "# .into(), "https://app.test/worker/main.js".into(), @@ -340,7 +456,7 @@ async fn worker_importscripts_report_only_csp_dispatches_without_blocking() { .expect("channel closed"); assert_eq!( expect_post_json(msg), - r#"{"events":[{"type":"securitypolicyviolation","effectiveDirective":"script-src","violatedDirective":"script-src","blockedURI":"data","documentURI":"https://app.test/worker/main.js","originalPolicy":"script-src 'none'","disposition":"report","instance":true}],"ran":true}"# + r#"{"event":{"type":"securitypolicyviolation","effectiveDirective":"script-src-elem","violatedDirective":"script-src-elem","blockedURI":"data","documentURI":"https://app.test/worker/main.js","originalPolicy":"script-src 'none'","disposition":"report","instance":true},"ran":true,"eventsAtReturn":0,"eventsAtMicrotask":0}"# ); } @@ -357,21 +473,22 @@ async fn shared_worker_importscripts_report_only_csp_dispatches_without_blocking WorkerSpawnOptions::new( r#" onconnect = () => { - let matched = false; + importScripts("data:text/javascript,globalThis.__ran=true"); + let microtaskRan = false; + queueMicrotask(() => microtaskRan = true); addEventListener("securitypolicyviolation", event => { - matched = event.type === "securitypolicyviolation" && - event.effectiveDirective === "script-src" && - event.violatedDirective === "script-src" && + const matched = event.type === "securitypolicyviolation" && + event.effectiveDirective === "script-src-elem" && + event.violatedDirective === "script-src-elem" && event.blockedURI === "data" && event.documentURI === "https://app.test/shared-worker.js" && event.originalPolicy === "script-src 'none'" && event.disposition === "report" && event instanceof SecurityPolicyViolationEvent; + if (matched && microtaskRan && globalThis.__ran === true) { + close(); + } }); - importScripts("data:text/javascript,globalThis.__ran=true"); - if (matched && globalThis.__ran === true) { - close(); - } }; "# .into(),