diff --git a/moli-renderer-v8/src/content_security_policy.rs b/moli-renderer-v8/src/content_security_policy.rs index 6913a64694..a812f6d2bf 100644 --- a/moli-renderer-v8/src/content_security_policy.rs +++ b/moli-renderer-v8/src/content_security_policy.rs @@ -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], + disposition: ContentSecurityPolicyDisposition, + reporting_endpoints: &ContentSecurityPolicyReportingEndpoints, +) -> Option { + 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 { + content_security_policy_frame_ancestors_violation_with_disposition_and_reporting_endpoints( + &policies + .iter() + .map(|policy| (*policy).to_owned()) + .collect::>(), + &request_url(protected_url), + &ancestor_origins + .iter() + .map(|ancestor| ancestor.map(request_url)) + .collect::>(), + 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( diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs index 0fd61f73e3..c30122edfc 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs @@ -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> { + 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( diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/child_document_completion.rs b/moli-renderer-v8/src/runtime/page_vm/tests/child_document_completion.rs index 8e852c599f..a0e1bbbaad 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/child_document_completion.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/child_document_completion.rs @@ -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 *", + "

allowed

".to_owned(), + Duration::ZERO, + ), + ( + "/blocked-child.html", + "HTTP/1.1 200 OK\r\nContent-Security-Policy: frame-ancestors 'none'", + "\ +

blocked

" + .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 {