From 6846be5cc29cfc4460aa2fda88bbf5e7c2941cbd 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 107875630..d144a43d6 100644 --- a/moli-renderer-v8/src/content_security_policy.rs +++ b/moli-renderer-v8/src/content_security_policy.rs @@ -2052,11 +2052,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, } } @@ -2073,7 +2074,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 bc4cdbaeb..81c4c4db8 100644 --- a/moli-renderer-v8/src/worker/thread/tests/modules.rs +++ b/moli-renderer-v8/src/worker/thread/tests/modules.rs @@ -156,32 +156,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()]), ); @@ -192,7 +202,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}"# ); } @@ -208,27 +316,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(), @@ -242,7 +350,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}"# ); } @@ -259,24 +367,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(), @@ -311,24 +422,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(), @@ -342,7 +458,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}"# ); } @@ -359,21 +475,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(),