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(),