fix(csp): enforce worker module import policies

This commit is contained in:
ldm0
2026-09-16 22:07:06 +08:00
parent 1486d634eb
commit f5145deffc
9 changed files with 192 additions and 43 deletions
@@ -44,6 +44,7 @@ pub(crate) enum ContentSecurityPolicyResourceKind {
DocumentStyleElement,
WorkerConstructor,
WorkerConnect,
WorkerDynamicModuleImport,
WorkerScript,
WorkerStaticModuleImport,
}
@@ -1910,7 +1911,7 @@ impl ContentSecurityPolicyResourceKind {
Self::DocumentImage => IMG_SRC,
Self::DocumentManifest => MANIFEST_SRC,
Self::DocumentMedia => MEDIA_SRC,
Self::DocumentScriptElement => SCRIPT_SRC_ELEM,
Self::DocumentScriptElement | Self::WorkerDynamicModuleImport => SCRIPT_SRC_ELEM,
Self::DocumentStyleElement => STYLE_SRC_ELEM,
Self::WorkerConstructor | Self::WorkerStaticModuleImport => WORKER_SRC,
Self::WorkerConnect => CONNECT_SRC,
@@ -1929,6 +1930,7 @@ impl ContentSecurityPolicyResourceKind {
Self::DocumentStyleElement => &[STYLE_SRC_ELEM, STYLE_SRC, DEFAULT_SRC],
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::WorkerStaticModuleImport => &[WORKER_SRC, CHILD_SRC, SCRIPT_SRC, DEFAULT_SRC],
}
@@ -2389,6 +2391,44 @@ mod tests {
));
}
#[test]
fn worker_module_imports_use_their_request_specific_directive_fallbacks() {
let cross_origin = "https://cdn.test/worker-import.js";
assert!(!allowed(
"worker-src 'self'; script-src *",
ContentSecurityPolicyResourceKind::WorkerStaticModuleImport,
cross_origin,
));
assert!(allowed(
"worker-src *; script-src 'self'",
ContentSecurityPolicyResourceKind::WorkerStaticModuleImport,
cross_origin,
));
assert!(!allowed(
"script-src-elem 'self'; script-src *",
ContentSecurityPolicyResourceKind::WorkerDynamicModuleImport,
cross_origin,
));
assert!(allowed(
"worker-src 'self'; script-src *",
ContentSecurityPolicyResourceKind::WorkerDynamicModuleImport,
cross_origin,
));
let violation = content_security_policy_url_violation_with_redirect_status_disposition_and_reporting_endpoints(
&["script-src 'self'".to_owned()],
&protected_url(),
&request_url(cross_origin),
ContentSecurityPolicyResourceKind::WorkerDynamicModuleImport,
ContentSecurityPolicyRedirectStatus::NoRedirect,
ContentSecurityPolicyDisposition::Enforce,
&ContentSecurityPolicyReportingEndpoints::default(),
)
.expect("script-src fallback should block the cross-origin dynamic import");
assert_eq!(violation.effective_directive, "script-src-elem");
}
#[test]
fn self_source_uses_csp_secure_upgrade_rules() {
let protected = Url::parse("http://app.test/page.html").unwrap();
@@ -149,7 +149,7 @@ pub(in crate::context_bootstrap) fn worker_constructor_callback<'s>(
return;
};
let global = scope.get_current_context().global(scope);
let document_content_security_policies =
let document_meta_content_security_policies =
current_document_content_security_policies(scope, global);
let document_referrer_policy = current_document_referrer_policy(scope, global);
let host = unsafe { &mut *host_ptr };
@@ -184,6 +184,18 @@ pub(in crate::context_bootstrap) fn worker_constructor_callback<'s>(
crate::native_bridge::WorkerOwnerScope::Top
};
let dispatch_scope = crate::native_bridge::OwnerDispatchScope::from(owner_scope);
let mut document_content_security_policies = if let Some(handle) = child_handle {
host.child_browsing_context_content_security_policies(handle)
.map(<[String]>::to_vec)
.unwrap_or_else(|| host.document_content_security_policies().to_vec())
} else if let Some(policies) =
host.active_lightweight_popup_content_security_policies(scope)
{
policies.to_vec()
} else {
host.document_content_security_policies().to_vec()
};
document_content_security_policies.extend(document_meta_content_security_policies);
let Some(creator_document_loader) =
host.document_resource_loader_for_dispatch_scope(dispatch_scope)
else {
@@ -421,6 +433,7 @@ pub(in crate::context_bootstrap) fn worker_constructor_callback<'s>(
worker_options.worker_type,
worker_options.credentials_mode,
document_referrer_policy,
document_content_security_policies,
worker_options.name.clone(),
reserved_service_worker_client_id,
)
@@ -11,6 +11,11 @@ pub(crate) enum WorkerOwnerScope {
LightweightPopup(u64),
}
pub(super) struct WorkerModuleStaticImportPolicySnapshot {
pub(super) initiator_url: url::Url,
pub(super) content_security_policies: Vec<String>,
}
pub(super) enum WorkerExecutionState {
Loading {
pending_messages: Vec<V8StructuredClonePayload>,
@@ -26,6 +31,7 @@ pub(super) enum WorkerExecutionState {
outside_settings_load: crate::network::loads::ResourceLoadLease,
name: String,
module_credentials_mode: moli_fetch::RequestCredentialsMode,
module_static_import_policy: Option<Box<WorkerModuleStaticImportPolicySnapshot>>,
storage_key_top_level_site: String,
creator_storage_key: MoliStorageKey,
reserved_service_worker_client_id:
@@ -1,5 +1,8 @@
use super::super::JsContextHost;
use super::{WorkerConnectionState, WorkerExecutionState, WorkerRelayTerminalState};
use super::{
WorkerConnectionState, WorkerExecutionState, WorkerModuleStaticImportPolicySnapshot,
WorkerRelayTerminalState,
};
use crate::RendererSyntheticResponseBody;
use crate::network::loads::{ResourceLoadDisposition, ResourceLoadKind, ResourceLoadLease};
use crate::page_task_queue::{
@@ -221,6 +224,7 @@ impl JsContextHost {
outside_settings_load,
name,
module_credentials_mode,
module_static_import_policy: None,
storage_key_top_level_site,
creator_storage_key,
reserved_service_worker_client_id,
@@ -240,6 +244,7 @@ impl JsContextHost {
script_kind: WorkerScriptKind,
module_credentials_mode: moli_fetch::RequestCredentialsMode,
document_referrer_policy: Option<String>,
document_content_security_policies: Vec<String>,
name: String,
reserved_service_worker_client_id: Option<ServiceWorkerClientId>,
) -> bool {
@@ -265,6 +270,10 @@ impl JsContextHost {
let cancel_handle = moli_fetch::FetchCancelHandle::new();
outside_settings_load.attach_cancel_handle(cancel_handle.clone());
let creator_secure_context = moli_url::is_potentially_trustworthy_url(&initiator_url);
let module_static_import_policy = Box::new(WorkerModuleStaticImportPolicySnapshot {
initiator_url: initiator_url.clone(),
content_security_policies: document_content_security_policies,
});
let task_runner = outside_settings_load.task_runner();
let fetch_task_runner = task_runner.clone();
let load_task = task_runner.spawn_abortable(async move {
@@ -329,6 +338,7 @@ impl JsContextHost {
load_task: slot,
terminated,
name: loading_name,
module_static_import_policy: loading_module_static_import_policy,
..
} => {
if *terminated {
@@ -336,6 +346,7 @@ impl JsContextHost {
return false;
}
*loading_name = name;
*loading_module_static_import_policy = Some(module_static_import_policy);
*slot = Some(load_task);
true
}
@@ -424,18 +435,21 @@ impl JsContextHost {
content_security_reporting_endpoints:
crate::content_security_policy::ContentSecurityPolicyReportingEndpoints,
) -> bool {
struct FinishLoadingWorkerSpawn {
pending_messages: Vec<V8StructuredClonePayload>,
name: String,
storage_key_top_level_site: String,
creator_storage_key: MoliStorageKey,
reserved_service_worker_client_id: Option<ServiceWorkerClientId>,
module_credentials_mode: moli_fetch::RequestCredentialsMode,
module_static_import_policy: Option<Box<WorkerModuleStaticImportPolicySnapshot>>,
request_client: crate::network::ResourceRequestClient,
}
enum FinishLoadingAction {
MissingOrRunning,
DiscardTerminated,
Spawn {
pending_messages: Vec<V8StructuredClonePayload>,
name: String,
storage_key_top_level_site: String,
creator_storage_key: MoliStorageKey,
reserved_service_worker_client_id: Option<ServiceWorkerClientId>,
module_credentials_mode: moli_fetch::RequestCredentialsMode,
request_client: crate::network::ResourceRequestClient,
},
Spawn(Box<FinishLoadingWorkerSpawn>),
}
let action = match self.workers.get_mut(&worker_id) {
@@ -446,6 +460,7 @@ impl JsContextHost {
terminated,
name,
module_credentials_mode,
module_static_import_policy,
storage_key_top_level_site,
creator_storage_key,
reserved_service_worker_client_id,
@@ -455,7 +470,7 @@ impl JsContextHost {
if *terminated {
FinishLoadingAction::DiscardTerminated
} else {
FinishLoadingAction::Spawn {
FinishLoadingAction::Spawn(Box::new(FinishLoadingWorkerSpawn {
pending_messages: std::mem::take(pending_messages),
name: name.clone(),
storage_key_top_level_site: storage_key_top_level_site.clone(),
@@ -463,45 +478,31 @@ impl JsContextHost {
reserved_service_worker_client_id: reserved_service_worker_client_id
.take(),
module_credentials_mode: *module_credentials_mode,
module_static_import_policy: module_static_import_policy.take(),
request_client: outside_settings_load.request_client(),
}
}))
}
}
WorkerExecutionState::Running { .. } => FinishLoadingAction::MissingOrRunning,
},
None => FinishLoadingAction::MissingOrRunning,
};
let (
let FinishLoadingWorkerSpawn {
pending_messages,
name,
storage_key_top_level_site,
creator_storage_key,
reserved_service_worker_client_id,
module_credentials_mode,
module_static_import_policy,
request_client,
) = match action {
} = match action {
FinishLoadingAction::MissingOrRunning => return false,
FinishLoadingAction::DiscardTerminated => {
self.forget_worker(worker_id);
return false;
}
FinishLoadingAction::Spawn {
pending_messages,
name,
storage_key_top_level_site,
creator_storage_key,
reserved_service_worker_client_id,
module_credentials_mode,
request_client,
} => (
pending_messages,
name,
storage_key_top_level_site,
creator_storage_key,
reserved_service_worker_client_id,
module_credentials_mode,
request_client,
),
FinishLoadingAction::Spawn(spawn) => *spawn,
};
let network_policy = WorkerNetworkPolicy {
secure_context,
@@ -538,6 +539,13 @@ impl JsContextHost {
self.browser_context_runtime()
.dedicated_worker_pause_on_start_for_devtools(),
);
if let Some(policy) = module_static_import_policy {
spawn_options = spawn_options
.with_module_static_import_initiator_url(policy.initiator_url)
.with_module_static_import_content_security_policies(
policy.content_security_policies,
);
}
if let Some(client_id) = reserved_service_worker_client_id {
spawn_options = spawn_options.with_reserved_service_worker_client_id(client_id);
}
@@ -2911,6 +2911,88 @@ async fn worker_pending_activity_diagnostics_split_loading_and_running_worker_is
.await;
}
#[tokio::test]
async fn external_dedicated_module_worker_retains_creator_csp_for_static_imports() {
run_page_vm_async_test(async move {
let (dependency_base_url, dependency_server) = spawn_path_response_http_server(vec![(
"/dependency.js",
"HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *",
"export const value = 'unexpected';".to_owned(),
Duration::ZERO,
)])
.await;
let dependency_url = format!("{dependency_base_url}/dependency.js");
let worker_source = format!(
r#"
import {dependency_url:?};
postMessage("unexpected");
"#
);
let (base_url, server) = spawn_path_response_http_server(vec![(
"/worker.js",
"HTTP/1.1 200 OK",
worker_source,
Duration::ZERO,
)])
.await;
let document_url = Url::parse(&format!("{base_url}/page.html")).expect("document url");
let mut page_vm = test_page_vm_with_document_url(document_url.clone());
page_vm.vm_mut().set_main_navigation_policy_container(
crate::document_runtime::DocumentPolicyContainer::from_navigation_response_headers(
&[(
"Content-Security-Policy".to_owned(),
"worker-src 'self'; script-src data:".to_owned(),
)],
&document_url,
),
);
let local_executor = page_vm.local_executor.clone();
local_executor
.run(async move {
page_vm.vm_mut().eval(
r#"
(() => {
globalThis.__moduleWorkerCspResult = null;
globalThis.__moduleWorkerCspDone = false;
const worker = new Worker("/worker.js", { type: "module" });
worker.onmessage = event => {
globalThis.__moduleWorkerCspResult = "message:" + event.data;
globalThis.__moduleWorkerCspDone = true;
};
worker.onerror = event => {
event.preventDefault();
globalThis.__moduleWorkerCspResult = "error:" + event.message;
globalThis.__moduleWorkerCspDone = true;
};
})()
"#,
)?;
drive_websocket_until_done(
&mut page_vm,
"String(globalThis.__moduleWorkerCspDone === true)",
"creator CSP should settle the external module worker",
)
.await?;
let result = page_vm
.vm_mut()
.eval("globalThis.__moduleWorkerCspResult")?;
assert!(
result.starts_with("error:") && result.contains("Content Security Policy"),
"static module import should be blocked by creator worker-src: {result:?}"
);
anyhow::Ok(())
})
.await
.expect("external module worker creator CSP test should run on owner lane");
server
.await
.expect("external module worker CSP server should finish");
dependency_server.abort();
})
.await;
}
#[tokio::test]
async fn service_worker_register_starts_module_worker_global() {
run_page_vm_async_test(async move {
+1 -1
View File
@@ -611,7 +611,7 @@ fn start_worker_module_graph_fetch(
worker_global_content_security_policies,
worker_global_content_security_report_only_policies,
worker_global_content_security_reporting_endpoints,
crate::content_security_policy::ContentSecurityPolicyResourceKind::WorkerScript,
crate::content_security_policy::ContentSecurityPolicyResourceKind::WorkerDynamicModuleImport,
),
};
let initial_csp_report_only_violation =
@@ -2225,7 +2225,7 @@ async fn worker_dynamic_import_uses_worker_response_csp_not_outside_static_csp()
.with_module_static_import_content_security_policies(vec![
"worker-src *; script-src 'self'".to_owned(),
])
.with_content_security_policies(vec!["script-src 'self'".to_owned()]),
.with_content_security_policies(vec!["script-src-elem 'self'; script-src *".to_owned()]),
);
let msg = timeout(TIMEOUT, handle.recv())
@@ -2234,7 +2234,7 @@ async fn worker_dynamic_import_uses_worker_response_csp_not_outside_static_csp()
.expect("channel closed");
assert_eq!(
expect_post_json(msg),
r#"{"status":"blocked","events":[{"type":"securitypolicyviolation","effectiveDirective":"script-src","violatedDirective":"script-src","blockedURI":"http://127.0.0.1:9/worker/dynamic.js","documentURI":"https://app.test/worker/main.js","originalPolicy":"script-src 'self'","disposition":"enforce","instance":true}],"name":"TypeError","csp":true}"#
r#"{"status":"blocked","events":[{"type":"securitypolicyviolation","effectiveDirective":"script-src-elem","violatedDirective":"script-src-elem","blockedURI":"http://127.0.0.1:9/worker/dynamic.js","documentURI":"https://app.test/worker/main.js","originalPolicy":"script-src-elem 'self'; script-src *","disposition":"enforce","instance":true}],"name":"TypeError","csp":true}"#
);
}
@@ -2391,8 +2391,8 @@ async fn shared_worker_dynamic_import_csp_block_dispatches_securitypolicyviolati
let matched = false;
addEventListener("securitypolicyviolation", event => {
matched = event.type === "securitypolicyviolation" &&
event.effectiveDirective === "script-src" &&
event.violatedDirective === "script-src" &&
event.effectiveDirective === "script-src-elem" &&
event.violatedDirective === "script-src-elem" &&
event.blockedURI === "http://127.0.0.1:9/worker/dynamic.js" &&
event.documentURI === "https://app.test/shared-worker.js" &&
event.originalPolicy === "script-src 'self'" &&
@@ -2661,7 +2661,7 @@ async fn worker_dynamic_import_report_only_csp_dispatches_without_blocking() {
assert_eq!(
expect_post_json(msg),
format!(
r#"{{"events":[{{"type":"securitypolicyviolation","effectiveDirective":"script-src","violatedDirective":"script-src","blockedURI":"{dep_url}","documentURI":"{script_url}","originalPolicy":"script-src 'none'","disposition":"report","instance":true}}],"value":42}}"#
r#"{{"events":[{{"type":"securitypolicyviolation","effectiveDirective":"script-src-elem","violatedDirective":"script-src-elem","blockedURI":"{dep_url}","documentURI":"{script_url}","originalPolicy":"script-src 'none'","disposition":"report","instance":true}}],"value":42}}"#
)
);
server
@@ -117,7 +117,7 @@ promise_test(async function () {
assert_equals(outcome.value, "dynamic-import-ok");
assert_true(
outcome.events.some(event => event.disposition === "report" &&
event.effectiveDirective === "script-src" &&
event.effectiveDirective === "script-src-elem" &&
event.instance &&
event.blockedURI.includes("/report-to-dynamic-dependency.js")),
"SharedWorker report-only CSP should dispatch a dynamic import SecurityPolicyViolationEvent",
@@ -126,7 +126,7 @@ promise_test(async function () {
const match = await wait_for_csp_report(token, report => {
return report.type === "csp-violation" &&
report.body.disposition === "report" &&
report.body.effectiveDirective === "script-src" &&
report.body.effectiveDirective === "script-src-elem" &&
report.body.blockedURL.includes("/report-to-dynamic-dependency.js");
});
assert_true(
@@ -82,7 +82,7 @@ promise_test(async function () {
"dedicated worker report-only CSP should dispatch an XHR SecurityPolicyViolationEvent",
);
assert_true(
has_event(outcome.events, "script-src", "worker-response-csp-report-to-dynamic-dependency.js"),
has_event(outcome.events, "script-src-elem", "worker-response-csp-report-to-dynamic-dependency.js"),
"dedicated worker report-only CSP should dispatch a dynamic import SecurityPolicyViolationEvent",
);
@@ -90,7 +90,7 @@ promise_test(async function () {
{ directive: "connect-src", blockedText: "target.txt?worker-fetch", label: "fetch" },
{ directive: "connect-src", blockedText: "target.txt?worker-xhr", label: "XHR" },
{
directive: "script-src",
directive: "script-src-elem",
blockedText: "worker-response-csp-report-to-dynamic-dependency.js",
label: "dynamic import",
},