mirror of
https://github.com/lexmount/moli.git
synced 2026-10-03 08:00:49 +00:00
fix(csp): enforce frame-ancestors on child responses
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user