fix(csp): enforce frame-ancestors on child responses

This commit is contained in:
ldm0
2026-08-26 20:39:31 +08:00
committed by Donough Liu
parent 9b5961086c
commit fc7c19e248
3 changed files with 348 additions and 8 deletions
@@ -15,6 +15,7 @@ use url::Url;
const CONNECT_SRC: &str = "connect-src";
const CHILD_SRC: &str = "child-src";
const DEFAULT_SRC: &str = "default-src";
const FRAME_ANCESTORS: &str = "frame-ancestors";
const FRAME_SRC: &str = "frame-src";
const IMG_SRC: &str = "img-src";
const MANIFEST_SRC: &str = "manifest-src";
@@ -734,6 +735,47 @@ pub(crate) fn content_security_policy_url_violation_with_redirect_status_disposi
)
}
pub(crate) fn content_security_policy_frame_ancestors_violation_with_disposition_and_reporting_endpoints(
policies: &[String],
protected_url: &Url,
ancestor_origins: &[Option<Url>],
disposition: ContentSecurityPolicyDisposition,
reporting_endpoints: &ContentSecurityPolicyReportingEndpoints,
) -> Option<ContentSecurityPolicyUrlViolation> {
policies.iter().find_map(|policy| {
let directives = parsed_directives(policy);
let source_list =
normalized_source_list(directive_source_list(&directives, FRAME_ANCESTORS)?.to_vec());
if ancestor_origins.iter().all(|ancestor_origin| {
ancestor_origin.as_ref().is_some_and(|ancestor_origin| {
frame_ancestor_source_list_allows(&source_list, protected_url, ancestor_origin)
})
}) {
return None;
}
let document_uri = csp_url_for_report(protected_url);
Some(ContentSecurityPolicyUrlViolation {
effective_directive: FRAME_ANCESTORS,
blocked_uri: document_uri.clone(),
source_file: document_uri.clone(),
document_uri,
original_policy: policy.clone(),
disposition,
report_uri_endpoints: content_security_policy_report_uri_endpoints(
policy,
protected_url,
),
report_to_endpoints: content_security_policy_report_to_endpoints(
policy,
reporting_endpoints,
),
sample: String::new(),
line_number: 0,
column_number: 0,
})
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn content_security_policy_script_element_url_violation_with_redirect_status_disposition_reporting_endpoints_and_request(
policies: &[String],
@@ -1202,6 +1244,26 @@ fn source_list_allows(
normalized_source_list_allows_url(&sources, protected_url, request_url, redirect_status)
}
fn frame_ancestor_source_list_allows(
sources: &[&str],
protected_url: &Url,
ancestor_origin: &Url,
) -> bool {
if sources.is_empty() || (sources.len() == 1 && csp_keyword_eq(sources[0], "none")) {
return false;
}
sources.iter().any(|source| {
!csp_keyword_eq(source, "none")
&& !source_has_path(source)
&& source_expression_matches(
source,
protected_url,
ancestor_origin,
ContentSecurityPolicyRedirectStatus::NoRedirect,
)
})
}
fn source_list_allows_script_element_request(
sources: Vec<&str>,
protected_url: &Url,
@@ -1943,6 +2005,26 @@ mod tests {
)
}
fn frame_ancestors_violation(
policies: &[&str],
protected_url: &str,
ancestor_origins: &[Option<&str>],
) -> Option<ContentSecurityPolicyUrlViolation> {
content_security_policy_frame_ancestors_violation_with_disposition_and_reporting_endpoints(
&policies
.iter()
.map(|policy| (*policy).to_owned())
.collect::<Vec<_>>(),
&request_url(protected_url),
&ancestor_origins
.iter()
.map(|ancestor| ancestor.map(request_url))
.collect::<Vec<_>>(),
ContentSecurityPolicyDisposition::Enforce,
&ContentSecurityPolicyReportingEndpoints::default(),
)
}
#[test]
fn content_security_policy_headers_collect_enforce_headers_only() {
let headers = vec![
@@ -1967,6 +2049,91 @@ mod tests {
);
}
#[test]
fn frame_ancestors_requires_every_ancestor_to_match() {
let protected_url = "https://child.test/frame.html";
let ancestors = [Some("https://child.test"), Some("https://top.test")];
let violation =
frame_ancestors_violation(&["frame-ancestors 'self'"], protected_url, &ancestors)
.expect("a cross-origin top ancestor must violate 'self'");
assert_eq!(violation.effective_directive, "frame-ancestors");
assert_eq!(violation.blocked_uri, protected_url);
assert!(
frame_ancestors_violation(&["frame-ancestors *"], protected_url, &ancestors).is_none()
);
assert!(
frame_ancestors_violation(
&["frame-ancestors https://child.test https://top.test"],
protected_url,
&ancestors,
)
.is_none()
);
assert!(
frame_ancestors_violation(
&["frame-ancestors https://child.test"],
protected_url,
&ancestors,
)
.is_some()
);
}
#[test]
fn frame_ancestors_has_no_fallback_and_allows_top_level_documents() {
assert!(
frame_ancestors_violation(
&["default-src 'none'"],
"https://child.test/frame.html",
&[Some("https://top.test")],
)
.is_none()
);
assert!(
frame_ancestors_violation(
&["frame-ancestors 'none'"],
"https://child.test/frame.html",
&[],
)
.is_none()
);
}
#[test]
fn every_frame_ancestors_policy_must_allow_embedding() {
let violation = frame_ancestors_violation(
&["frame-ancestors *", "frame-ancestors 'none'"],
"https://child.test/frame.html",
&[Some("https://top.test")],
)
.expect("one blocking policy must block the response");
assert_eq!(violation.original_policy, "frame-ancestors 'none'");
}
#[test]
fn frame_ancestors_matches_origins_and_rejects_opaque_ancestors() {
assert!(
frame_ancestors_violation(
&["frame-ancestors https://top.test/path"],
"https://child.test/frame.html",
&[Some("https://top.test")],
)
.is_some(),
"a host source containing a path must not match an ancestor origin"
);
assert!(
frame_ancestors_violation(
&["frame-ancestors *"],
"https://child.test/frame.html",
&[None],
)
.is_some(),
"an opaque ancestor origin must not match a source expression"
);
}
#[test]
fn worker_src_none_blocks_shared_worker_script() {
assert!(!allowed(
@@ -1,11 +1,16 @@
use super::super::{ChildBrowsingContextBootstrap, JsContextHost};
use super::super::{ChildBrowsingContextBootstrap, JsContextHost, OwnerDispatchScope};
use super::{
ChildDocumentNavigationInitiator, PendingChildDocumentNavigation,
configure_child_document_navigation_request,
snapshots::{child_document_content_type_for_url, child_document_content_type_from_headers},
};
use crate::{
content_security_policy::content_security_policy_reporting_endpoints_from_headers,
content_security_policy::{
ContentSecurityPolicyDisposition, ContentSecurityPolicyViolationEventFields,
content_security_policy_frame_ancestors_violation_with_disposition_and_reporting_endpoints,
content_security_policy_reporting_endpoints_from_headers,
send_content_security_policy_reports,
},
document_runtime::{
DocumentPolicyContainer, DocumentSandboxPolicy, DomHandle,
response_content_security_policies_from_headers,
@@ -402,6 +407,64 @@ impl JsContextHost {
handle,
target.load_id(),
);
let result = match result {
Ok(ChildDocumentLoadOutcome::Loaded(mut loaded)) => {
if self.bypass_content_security_policy() {
loaded
.policy_container
.clear_content_security_policy_for_bypass();
}
let ancestor_origins = self.child_document_frame_ancestor_origins(handle);
let reporting_endpoints =
&loaded.policy_container.content_security_reporting_endpoints;
let report_only_violation =
content_security_policy_frame_ancestors_violation_with_disposition_and_reporting_endpoints(
&loaded
.policy_container
.response_content_security_report_only_policies,
&loaded.final_url,
&ancestor_origins,
ContentSecurityPolicyDisposition::Report,
reporting_endpoints,
);
let enforced_violation =
content_security_policy_frame_ancestors_violation_with_disposition_and_reporting_endpoints(
&loaded.policy_container.response_content_security_policies,
&loaded.final_url,
&ancestor_origins,
ContentSecurityPolicyDisposition::Enforce,
reporting_endpoints,
);
for violation in report_only_violation
.iter()
.chain(enforced_violation.iter())
{
// The protected response never receives a Document when enforcement blocks
// it, so there is no target on which to dispatch a DOM event. Keep the
// violation observable to DevTools on the embedding frame owner instead.
// SAFETY: JsContextHost is owned by the ScriptVm that owns this DocumentRuntime.
unsafe { &mut *self.runtime }
.record_content_security_policy_inspector_issue(Some(handle), violation);
let fields =
ContentSecurityPolicyViolationEventFields::from_url_violation(violation);
send_content_security_policy_reports(
pending.resource_loader.request_client(),
&fields,
&violation.report_uri_endpoints,
&violation.report_to_endpoints,
);
}
if let Some(violation) = enforced_violation {
Err(format!(
"child document response blocked by Content Security Policy `{}` for `{}`",
violation.effective_directive, violation.blocked_uri
))
} else {
Ok(ChildDocumentLoadOutcome::Loaded(loaded))
}
}
result => result,
};
let document_credentialless = pending.document_credentialless;
let credentialless_storage_nonce = pending.credentialless_storage_nonce;
let replaces_existing_document = self
@@ -436,12 +499,7 @@ impl JsContextHost {
};
}
Ok(ChildDocumentLoadOutcome::Loaded(loaded)) => {
let mut loaded = *loaded;
if self.bypass_content_security_policy() {
loaded
.policy_container
.clear_content_security_policy_for_bypass();
}
let loaded = *loaded;
if self.dispatch_child_browsing_context_unload_lifecycle_if_needed(scope, handle) {
body_activity = ChildDocumentLoadBodyActivity::PageCodeOrEventDispatch;
}
@@ -651,6 +709,35 @@ impl JsContextHost {
pending.target.request_id(),
);
}
fn child_document_frame_ancestor_origins(&self, handle: DomHandle) -> Vec<Option<url::Url>> {
let mut ancestors = Vec::new();
let mut current = handle;
for _ in 0..=self.child_browsing_contexts.len() {
let parent = self.child_browsing_context_parent_handle(current);
let owner_scope = match parent {
Some(parent) => OwnerDispatchScope::Child(parent),
None => self.child_browsing_context_popup_owner_id(current).map_or(
OwnerDispatchScope::Top,
OwnerDispatchScope::LightweightPopup,
),
};
let origin = self
.window_access_origin_for_dispatch_scope(owner_scope)
.and_then(|origin| {
let serialized = origin.serialized_origin();
(serialized != "null")
.then(|| url::Url::parse(&serialized).ok())
.flatten()
});
ancestors.push(origin);
let Some(parent) = parent else {
break;
};
current = parent;
}
ancestors
}
}
fn child_document_fallback_character_set(
@@ -63,10 +63,12 @@ async fn start_external_child_document_load(
.vm_mut()
.eval(&format!(
r#"
{{
const frame = document.createElement("iframe");
frame.id = {frame_id:?};
frame.src = {child_url:?};
document.body.appendChild(frame);
}}
"#
))
.expect("external child fixture should be created");
@@ -155,6 +157,90 @@ async fn production_child_document_fetch_reaches_stable_typed_turn_and_commits()
.expect("production typed child-document test should run");
}
#[tokio::test(flavor = "current_thread")]
async fn child_document_response_frame_ancestors_gates_commit() {
run_page_vm_async_test(async move {
let (base_url, server) = spawn_path_response_http_server(vec![
(
"/allowed-child.html",
"HTTP/1.1 200 OK\r\nContent-Security-Policy: frame-ancestors *",
"<!doctype html><p id='allowed-child'>allowed</p>".to_owned(),
Duration::ZERO,
),
(
"/blocked-child.html",
"HTTP/1.1 200 OK\r\nContent-Security-Policy: frame-ancestors 'none'",
"<!doctype html><script>parent.__blockedChildExecuted = true</script>\
<p id='must-not-commit'>blocked</p>"
.to_owned(),
Duration::ZERO,
),
])
.await;
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let (mut page_vm, mut queue, mut wake_rx) = owner_attached_page_vm(
&loader,
Url::parse(&format!("{base_url}/page.html")).expect("page URL"),
);
let allowed_url = format!("{base_url}/allowed-child.html");
start_external_child_document_load(&mut page_vm, "frame-ancestors-allowed", &allowed_url)
.await;
wait_for_page_resource_completion(&mut queue, &mut wake_rx, "allowed child fetch").await;
page_vm
.apply_one_page_resource_terminal_owner_admission_for_test(&mut queue)?
.expect("allowed child terminal should apply");
assert_eq!(
page_vm.vm_mut().eval(
"document.getElementById('frame-ancestors-allowed').contentDocument\
.getElementById('allowed-child').textContent"
)?,
"allowed"
);
page_vm
.vm_mut()
.eval("globalThis.__blockedChildExecuted = false")?;
let blocked_url = format!("{base_url}/blocked-child.html");
start_external_child_document_load(&mut page_vm, "frame-ancestors-blocked", &blocked_url)
.await;
wait_for_page_resource_completion(&mut queue, &mut wake_rx, "blocked child fetch").await;
page_vm
.apply_one_page_resource_terminal_owner_admission_for_test(&mut queue)?
.expect("blocked child terminal should settle");
assert_eq!(
page_vm.vm_mut().eval(
r#"(() => {
const frame = document.getElementById("frame-ancestors-blocked");
try {
void frame.contentWindow.location.href;
return "accessible";
} catch (_) {
return "blocked";
}
})()"#
)?,
"blocked",
"a blocked response must leave no ancestor-accessible child Document"
);
assert_eq!(
page_vm
.vm_mut()
.eval("String(globalThis.__blockedChildExecuted)")?,
"false",
"blocked response script must never execute"
);
server
.await
.expect("frame-ancestors child server should finish");
Ok::<_, anyhow::Error>(())
})
.await
.expect("frame-ancestors response gate test should run");
}
#[tokio::test(flavor = "current_thread")]
async fn failed_child_document_fetch_is_applied_only_to_its_exact_current_request() {
run_page_vm_async_test(async move {