diff --git a/moli-csp/src/reporting.rs b/moli-csp/src/reporting.rs index 1df2503dfb6..95780ebce50 100644 --- a/moli-csp/src/reporting.rs +++ b/moli-csp/src/reporting.rs @@ -185,7 +185,8 @@ pub fn content_security_policy_reporting_api_report_body( .to_string() } -pub(crate) fn csp_url_for_report(url: &Url) -> String { +/// Strip a protected document URL for inclusion in a CSP violation report. +pub fn csp_url_for_report(url: &Url) -> String { if !matches!(url.scheme(), "http" | "https" | "ws" | "wss") { return url.scheme().to_owned(); } diff --git a/moli-renderer-v8/src/document_runtime.rs b/moli-renderer-v8/src/document_runtime.rs index 830fb663da1..9825651a7d5 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -638,6 +638,9 @@ pub(crate) struct DocumentPolicyContainer { pub(crate) document_isolation_policy: crate::cross_origin_isolation::DocumentIsolationPolicy, pub(crate) cross_origin_isolated: bool, pub(crate) document_content_security_policies: Vec, + /// Captured with the policy container; local documents inherit this value + /// independently of their base URL and sandboxed Window origin. + pub(crate) content_security_policy_self_url: Option, pub(crate) response_content_security_policies: Vec, pub(crate) response_content_security_report_only_policies: Vec, pub(crate) content_security_reporting_endpoints: diff --git a/moli-renderer-v8/src/document_runtime/security_policy.rs b/moli-renderer-v8/src/document_runtime/security_policy.rs index 26fe57dd89e..dc24a9087db 100644 --- a/moli-renderer-v8/src/document_runtime/security_policy.rs +++ b/moli-renderer-v8/src/document_runtime/security_policy.rs @@ -181,6 +181,7 @@ impl DocumentPolicyContainer { final_url, headers, ), document_content_security_policies: response_content_security_policies.clone(), + content_security_policy_self_url: Some(final_url.clone()), sandbox, response_content_security_policies, response_content_security_report_only_policies: @@ -828,6 +829,7 @@ impl DocumentRuntime { &self.policy_container, request_url, kind, + ContentSecurityPolicyRedirectStatus::NoRedirect, ) } @@ -838,29 +840,42 @@ impl DocumentRuntime { policy_container: &DocumentPolicyContainer, request_url: &Url, kind: DocumentSubresourceCspKind, + redirect_status: ContentSecurityPolicyRedirectStatus, ) -> DocumentContentSecurityPolicyCheck { + let policy_url = policy_container + .content_security_policy_self_url + .as_ref() + .unwrap_or(document_url); let policies = self.document_content_security_policies_for_optional_document( document_handle, &policy_container.response_content_security_policies, &policy_container.content_security_reporting_endpoints, ); - let enforced_violation = document_url_policy_violation_from_document_policies( + let mut enforced_violation = document_url_policy_violation_from_document_policies( policies, - document_url, + policy_url, request_url, kind.resource_kind(), - ContentSecurityPolicyRedirectStatus::NoRedirect, + redirect_status, ContentSecurityPolicyDisposition::Enforce, ); - let report_only_violation = document_url_policy_violation( + let mut report_only_violation = document_url_policy_violation( &policy_container.response_content_security_report_only_policies, &policy_container.content_security_reporting_endpoints, - document_url, + policy_url, request_url, kind.resource_kind(), - ContentSecurityPolicyRedirectStatus::NoRedirect, + redirect_status, ContentSecurityPolicyDisposition::Report, ); + let document_uri = crate::content_security_policy::csp_url_for_report(document_url); + for violation in [&mut report_only_violation, &mut enforced_violation] + .into_iter() + .flatten() + { + violation.document_uri.clone_from(&document_uri); + violation.source_file.clone_from(&document_uri); + } DocumentContentSecurityPolicyCheck { report_only_violation, enforced_violation, @@ -2371,6 +2386,51 @@ mod tests { ) } + #[test] + fn image_csp_inherited_source_is_independent_of_local_document_and_report_url() { + let runtime = runtime_for_html(""); + let creator = Url::parse("https://creator.example.test/page").unwrap(); + let local_document = Url::parse("about:srcdoc").unwrap(); + let inherited = DocumentPolicyContainer::from_navigation_response_headers( + &[ + ("Content-Security-Policy".into(), b"img-src 'self'".to_vec()), + ( + "Content-Security-Policy-Report-Only".into(), + b"img-src 'self'".to_vec(), + ), + ], + &creator, + ); + assert_eq!( + inherited.content_security_policy_self_url.as_ref(), + Some(&creator) + ); + let allowed = runtime.document_subresource_csp_check_for_document( + None, + &local_document, + &inherited, + &creator.join("image.png").unwrap(), + DocumentSubresourceCspKind::Image, + ContentSecurityPolicyRedirectStatus::NoRedirect, + ); + assert!(allowed.has_no_violations()); + let (report_only, enforced) = runtime + .document_subresource_csp_check_for_document( + None, + &local_document, + &inherited, + &Url::parse("https://other.example.test/image.png").unwrap(), + DocumentSubresourceCspKind::Image, + ContentSecurityPolicyRedirectStatus::FollowedRedirect, + ) + .into_violations(); + for violation in [report_only.unwrap(), enforced.unwrap()] { + assert_eq!(violation.document_uri, "about"); + assert_eq!(violation.source_file, "about"); + assert_eq!(violation.effective_directive, "img-src"); + } + } + #[test] fn csp_script_src_self_blocks_cross_origin_script_element_request() { let runtime = runtime_for_html( @@ -2542,6 +2602,7 @@ mod tests { &policy, &request_url, DocumentSubresourceCspKind::Image, + ContentSecurityPolicyRedirectStatus::NoRedirect, ); let violation = check .enforced_violation() diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/initial_empty.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/initial_empty.rs index 24e6225d16d..ff2dad90d3e 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/initial_empty.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/initial_empty.rs @@ -36,6 +36,9 @@ impl JsContextHost { .map(|entry| entry.document_policy_container_snapshot()) }) .unwrap_or_else(|| self.document_policy_container().clone()); + policy_container + .content_security_policy_self_url + .get_or_insert_with(|| self.document_url_for_child_context(handle)); if let Some(parent_document) = self .dom_host() .node(handle) diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/snapshots.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/snapshots.rs index 879ec02bb93..43a32d9cf6f 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/snapshots.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/snapshots.rs @@ -24,10 +24,13 @@ impl JsContextHost { handle: DomHandle, url: &Url, ) -> Option { - let snapshot = self.apply_page_csp_bypass_to_child_snapshot( + let mut snapshot = self.apply_page_csp_bypass_to_child_snapshot( self.materialize_local_child_snapshot_for_url(url)?, ); if moli_url::is_about_blank(&snapshot.url) { + snapshot.policy_container.content_security_policy_self_url = self + .initial_child_about_blank_policy_container_from_parent(handle) + .content_security_policy_self_url; Some(snapshot.with_fallback_base_url(self.document_base_url_for_child_context(handle))) } else { Some(snapshot) @@ -102,13 +105,17 @@ impl JsContextHost { ), ) } - ChildBrowsingContextBootstrap::Srcdoc { base_url, markup } => Some( - self.apply_page_csp_bypass_to_child_snapshot(ChildBrowsingContextSnapshot::srcdoc( + ChildBrowsingContextBootstrap::Srcdoc { base_url, markup } => { + let mut snapshot = ChildBrowsingContextSnapshot::srcdoc( base_url.clone(), markup.clone(), self.document_character_set().to_owned(), - )), - ), + ); + snapshot.policy_container.content_security_policy_self_url = self + .initial_child_about_blank_policy_container_from_parent(handle) + .content_security_policy_self_url; + Some(self.apply_page_csp_bypass_to_child_snapshot(snapshot)) + } ChildBrowsingContextBootstrap::Url(url) => { self.materialize_local_child_snapshot_for_navigation_url(handle, url) } diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frames.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frames.rs index 621d080737b..1d54ee98e38 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frames.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frames.rs @@ -333,6 +333,9 @@ impl ChildBrowsingContextEntry { credentialless: bool, credentialless_storage_nonce: Option, ) { + self.document_policy_container + .content_security_policy_self_url = + policy_container.content_security_policy_self_url.clone(); self.document_policy_container.referrer_policy = policy_container.referrer_policy.clone(); self.document_policy_container.cross_origin_embedder_policy = policy_container.cross_origin_embedder_policy; @@ -366,6 +369,11 @@ impl ChildBrowsingContextEntry { &mut self, snapshot: &ChildBrowsingContextSnapshot, ) { + self.document_policy_container + .content_security_policy_self_url = snapshot + .policy_container + .content_security_policy_self_url + .clone(); let sandbox = self .document_policy_container .sandbox diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs index b6741bf44cc..e9e16da2f58 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs @@ -389,6 +389,8 @@ impl JsContextHost { .unwrap_or_else(|| creator_document_url.to_string()) }; let document_policy_container = ChildDocumentPolicyContainer { + content_security_policy_self_url: refresh_policy_source + .and_then(|policy| policy.content_security_policy_self_url.clone()), document_referrer, referrer_policy: refresh_policy_source .and_then(|policy| policy.referrer_policy.clone()), diff --git a/moli-renderer-v8/src/native_bridge/context_host/image_resources/css.rs b/moli-renderer-v8/src/native_bridge/context_host/image_resources/css.rs index b8167afa1f4..052f3a57a8f 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/image_resources/css.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/image_resources/css.rs @@ -187,7 +187,7 @@ impl CssImageResourceStore { return false; }; let descriptor = match &slot.state { - CssImageResourceState::DecodeQueued(descriptor) => *descriptor, + CssImageResourceState::DecodeQueued(descriptor) => descriptor.clone(), _ => return false, }; let (pixels, svg) = match ready.content { @@ -381,7 +381,7 @@ mod tests { else { panic!("the first exact request must fetch"); }; - assert!(store.mark_decode_queued(&first, descriptor)); + assert!(store.mark_decode_queued(&first, descriptor.clone())); let CssImageResourceAdmission::Fetch(replacement) = store.admit(first_document, owner(2), url.to_owned()) @@ -392,7 +392,7 @@ mod tests { !store.complete_decode(&first, ready_red_pixel()), "the retired owner cannot commit into the replacement slot" ); - assert!(store.mark_decode_queued(&replacement, descriptor)); + assert!(store.mark_decode_queued(&replacement, descriptor.clone())); assert!(store.complete_decode(&replacement, ready_red_pixel())); assert!( !store.fail(&replacement), diff --git a/moli-renderer-v8/src/native_bridge/context_host/image_resources/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/image_resources/mod.rs index bbf542d65e7..ac476b82aee 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/image_resources/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/image_resources/mod.rs @@ -27,11 +27,12 @@ pub(super) struct ImageResourceRequestIdentity { pub(super) request_key: ImageRequestKey, } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub(crate) struct ImageResponseDescriptor { pub(crate) width: u32, pub(crate) height: u32, pub(super) decode_metadata: ImageDecodeMetadata, + pub(crate) response_url: Option>, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -55,6 +56,7 @@ impl ImageResponseDescriptor { width: metadata.width, height: metadata.height, decode_metadata: ImageDecodeMetadata::Raster(metadata), + response_url: None, } } @@ -64,6 +66,7 @@ impl ImageResponseDescriptor { width, height, decode_metadata: ImageDecodeMetadata::Svg(metadata), + response_url: None, }) } } @@ -260,7 +263,7 @@ impl super::JsContextHost { if !self .image_resources .css - .mark_decode_queued(identity, descriptor) + .mark_decode_queued(identity, descriptor.clone()) { return false; } @@ -307,6 +310,39 @@ impl super::JsContextHost { self.image_resources.is_ready(element) } + pub(crate) fn cached_image_response_url( + &self, + element: DomHandle, + ) -> Option> { + let identity = self.image_resources.identity(element)?; + self.image_resources + .ready_resource(&identity.request_key)? + .descriptor + .response_url + .clone() + .filter(|url| url.as_str() != identity.request_key.url()) + } + + pub(crate) fn cached_css_image_response_url( + &self, + request_url: &url::Url, + ) -> Option> { + let key = ImageRequestKey::with_density( + request_url.to_string(), + crate::types::ImageRequestCorsMode::NoCors, + 1.0, + ); + let resource = self.image_resources.ready_resource(&key)?; + if resource.pixels.is_none() && resource.svg.is_none() { + return None; + } + resource + .descriptor + .response_url + .clone() + .filter(|url| url.as_ref() != request_url) + } + pub(crate) fn has_ready_image_request(&self, request_key: &ImageRequestKey) -> bool { self.image_resources.has_ready_request(request_key) } @@ -422,7 +458,11 @@ impl super::JsContextHost { .request_client() .optional_resource_fetch_enabled(crate::types::SubresourceResourceType::Image) }); - if decode_enabled && self.image_resources.complete_shared_ready(&identity) { + if decode_enabled + && self + .image_resources + .complete_shared_ready(&identity, descriptor.response_url.as_deref()) + { let Some(pending) = self.pending_image_load_events.get_mut(&element) else { return ImageResponseCompletion::Ignored; }; @@ -434,7 +474,7 @@ impl super::JsContextHost { if decode_enabled { if !self .image_resources - .mark_decode_queued(&identity, descriptor) + .mark_decode_queued(&identity, descriptor.clone()) { return ImageResponseCompletion::Ignored; } diff --git a/moli-renderer-v8/src/native_bridge/context_host/image_resources/preload.rs b/moli-renderer-v8/src/native_bridge/context_host/image_resources/preload.rs index d2e907a1a71..3694e08d932 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/image_resources/preload.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/image_resources/preload.rs @@ -565,7 +565,7 @@ mod tests { moli_image::RgbaImage::try_new(1, 1, vec![1, 2, 3, 255]).expect("valid image pixels"); let encoded = moli_image::encode_png(&pixels).expect("valid PNG"); let response = crate::protocol_types::NavigationResponse::from_text_body( - url, + url.clone(), 200, vec![("Content-Type".to_owned(), b"image/png".to_vec())], String::new(), @@ -598,7 +598,11 @@ mod tests { drop(claimed); drop(outcome); drop(load); - assert!(resources.complete_shared_ready(&identity)); + assert!(!resources.complete_shared_ready( + &identity, + Some(&url::Url::parse("https://other.test/image.png").unwrap()), + )); + assert!(resources.complete_shared_ready(&identity, Some(&url))); let ready = resources .ready_for_layout(element) .expect("the exact element should bind the decoded preload"); diff --git a/moli-renderer-v8/src/native_bridge/context_host/image_resources/state.rs b/moli-renderer-v8/src/native_bridge/context_host/image_resources/state.rs index fc6210ed24b..58c876acbca 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/image_resources/state.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/image_resources/state.rs @@ -183,7 +183,7 @@ impl ImageResourceStore { identity: current, state: ImageResourceState::DecodeQueued(descriptor), .. - }) if current == identity => *descriptor, + }) if current == identity => descriptor.clone(), _ => return false, }; let (pixels, svg) = match ready.content { @@ -202,10 +202,14 @@ impl ImageResourceStore { pub(super) fn complete_shared_ready( &mut self, identity: &ImageResourceRequestIdentity, + response_url: Option<&url::Url>, ) -> bool { let Some(resource) = self.ready_by_request.get(&identity.request_key) else { return false; }; + if resource.descriptor.response_url.as_deref() != response_url { + return false; + } let Some(slot) = self.slots.get_mut(&identity.element) else { return false; }; @@ -293,6 +297,13 @@ impl ImageResourceStore { self.ready_by_request.contains_live(request_key) } + pub(super) fn ready_resource( + &self, + request_key: &ImageRequestKey, + ) -> Option> { + self.ready_by_request.get(request_key) + } + pub(super) fn intrinsic_dimensions(&self, element: DomHandle) -> Option<(f32, f32)> { let slot = self.slots.get(&element)?; let ImageResourceState::Ready(resource) = &slot.state else { diff --git a/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs b/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs index 64071829721..dfc0e33aa1c 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs @@ -356,11 +356,39 @@ impl JsContextHost { let Some(owner) = self.owner_dispatch_scope_for_node(element) else { return DocumentCspOutcome::Allowed; }; + let outcome = self.check_owner_subresource_csp( + scope, + owner, + request_url, + kind, + ContentSecurityPolicyRedirectStatus::NoRedirect, + ); + if !outcome.blocks_request() + && matches!(kind, DocumentSubresourceCspKind::Image) + && let Some(response_url) = self.cached_image_response_url(element) + { + return self.check_owner_subresource_csp( + scope, + owner, + &response_url, + kind, + ContentSecurityPolicyRedirectStatus::FollowedRedirect, + ); + } + outcome + } + + pub(crate) fn check_owner_subresource_csp<'s>( + &mut self, + scope: &mut v8::PinScope<'s, '_>, + owner: OwnerDispatchScope, + request_url: &url::Url, + kind: DocumentSubresourceCspKind, + redirect_status: ContentSecurityPolicyRedirectStatus, + ) -> DocumentCspOutcome { let Some(snapshot) = self.owner_document_policy_snapshot(owner) else { return DocumentCspOutcome::Allowed; }; - // The element's Document owns this request even when a different - // Window's script changes its source or queues the update microtask. // SAFETY: this host and its DocumentRuntime are owned by the same ScriptVm. let (report_only_violation, enforced_violation) = unsafe { &*self.runtime } .document_subresource_csp_check_for_document( @@ -369,6 +397,7 @@ impl JsContextHost { &snapshot.policy_container, request_url, kind, + redirect_status, ) .into_violations(); let host_ptr: *mut JsContextHost = self; diff --git a/moli-renderer-v8/src/network_host/image.rs b/moli-renderer-v8/src/network_host/image.rs index 516f5579003..a42ccb77163 100644 --- a/moli-renderer-v8/src/network_host/image.rs +++ b/moli-renderer-v8/src/network_host/image.rs @@ -435,7 +435,10 @@ pub(crate) fn image_response_descriptor_from_parkable( encoded: &moli_parkable_image::ParkableImage, ) -> Option { let snapshot = encoded.snapshot().ok()?; - image_response_descriptor_from_bytes(&response.headers, &snapshot) + image_response_descriptor_from_bytes(&response.headers, &snapshot).map(|mut descriptor| { + descriptor.response_url = Some(std::sync::Arc::new(response.final_url.clone())); + descriptor + }) } fn image_response_descriptor_from_bytes( diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs b/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs index 498ef943340..c12c889ff94 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs @@ -500,6 +500,18 @@ impl ScriptVm { let security_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now); let mut opaque_response_blocked = false; let result = result.and_then(|response| { + if pending.info.resource_type == SubresourceResourceType::Image + && (!response.redirect_chain.is_empty() || response.final_url != pending.info.url) + && let Some(violation) = context_host.borrow_mut().check_owner_subresource_csp( + scope, + pending.execution_context.dispatch_scope(), + &response.final_url, + crate::document_runtime::DocumentSubresourceCspKind::Image, + crate::content_security_policy::ContentSecurityPolicyRedirectStatus::FollowedRedirect, + ).into_blocking_violation() + { + return Err(crate::document_runtime::document_content_security_policy_error_message(&violation, "image")); + } if matches!(pending.continuation, PendingSubresourceContinuation::FontFace(_)) && !response.redirect_chain.is_empty() && !context_host.borrow_mut().font_request_allowed_by_csp(scope, pending.execution_context.dispatch_scope(), &pending.info.document_url, &response.final_url, crate::content_security_policy::ContentSecurityPolicyRedirectStatus::FollowedRedirect) { diff --git a/moli-renderer-v8/src/script_vm/tests/canvas_webgl.rs b/moli-renderer-v8/src/script_vm/tests/canvas_webgl.rs index 090b277c44c..9dffea36471 100644 --- a/moli-renderer-v8/src/script_vm/tests/canvas_webgl.rs +++ b/moli-renderer-v8/src/script_vm/tests/canvas_webgl.rs @@ -2956,6 +2956,83 @@ const IMAGE_CSP_CROSS_REALM_PROBE: &str = r#" })() "#; +#[tokio::test] +async fn html_image_csp_self_in_srcdoc_uses_creator_url_and_keeps_owner_reports() { + for sandboxed in [false, true] { + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://image-csp-self.test/page.html", + &loader, + ); + vm.eval(&format!( + r#" +globalThis.__imageSelfEvents = []; +globalThis.__imageSelfOrigins = []; +const parentBase = document.createElement('base'); +parentBase.href = 'https://parent-base.test/'; +document.head.appendChild(parentBase); +globalThis.__imageSelfFrame = document.createElement('iframe'); +if ({sandboxed}) __imageSelfFrame.sandbox = 'allow-scripts'; +addEventListener('message', event => {{ + if (event.source === __imageSelfFrame.contentWindow) {{ + __imageSelfEvents.push(event.data); + __imageSelfOrigins.push(event.origin); + }} +}}); +__imageSelfFrame.srcdoc = ` + + +"#, + ) + } else { + ( + "200 OK", + "Content-Type: image/gif\r\n".to_owned(), + b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xff\xff\xff!\xf9\x04\x01\0\0\0\0,\0\0\0\0\x01\0\x01\0\0\x02\x02D\x01\0;", + ) + }; + let head = format!( + "HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + socket.write_all(head.as_bytes()).await.unwrap(); + socket.write_all(body).await.unwrap(); + } + }); + Self { + origin, + cross_origin, + requests, + task, + port, + } + } + + fn loader(&self) -> crate::network::ResourceRequestClientOwner { + let loader = static_http_loader([ + format!("image-csp.test:{}:127.0.0.1", self.port), + format!("cross-image-csp.test:{}:127.0.0.1", self.port), + ]); + loader.set_image_fetch_enabled(true); + loader + } +} + +impl Drop for ImageCspServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn loaded_child( + vm: &mut crate::runtime::PageVmTaskExecutorTestHarness, + loader: &ResourceRequestClient, + setup: &str, +) { + vm.eval(&format!( + r#" + globalThis.frameLoaded = false; + globalThis.frame = document.createElement('iframe'); + frame.onload = () => {{ frameLoaded = true; }}; + {setup} + document.body.append(frame); + "# + )) + .unwrap(); + advance_page_task_executor_until_eval_equals( + vm, + loader, + "String(frameLoaded)", + "true", + "child document must load", + ) + .await; +} + +fn set_header_policy( + vm: &mut crate::runtime::PageVmTaskExecutorTestHarness, + document_url: &str, + policy: &str, + report_only: bool, +) { + let header = if report_only { + "Content-Security-Policy-Report-Only" + } else { + "Content-Security-Policy" + }; + vm.set_main_navigation_policy_container( + crate::document_runtime::DocumentPolicyContainer::from_navigation_response_headers( + &[(header.to_owned(), policy.as_bytes().to_vec())], + &Url::parse(document_url).unwrap(), + ), + ); +} + +#[tokio::test] +async fn image_csp_redirects_check_final_url_with_child_policy_and_report_owner() { + for (cross, report_only, path_source) in [ + (false, false, false), + (true, false, false), + (true, true, false), + (false, false, true), + ] { + let server = ImageCspServer::spawn().await; + let loader = server.loader(); + let document_url = format!("{}/page.html", server.origin); + let mut vm = new_storage_page_task_executor_test_vm_with_loader(&document_url, &loader); + let policy = if path_source { + format!("img-src {}/allowed/", server.origin) + } else { + "img-src 'self'".to_owned() + }; + set_header_policy(&mut vm, &document_url, &policy, report_only); + loaded_child(&mut vm, &loader, "frame.srcdoc = '';").await; + let path = if cross { + "/allowed/redirect-cross" + } else { + "/allowed/redirect" + }; + vm.eval(&format!(r#" + globalThis.events = []; + addEventListener('securitypolicyviolation', () => events.push('parent-report')); + frame.contentWindow.addEventListener('securitypolicyviolation', event => {{ + events.push([event.disposition, event.effectiveDirective, event.documentURI, event.blockedURI].join('|')); + }}); + globalThis.imageResult = 'pending'; + const image = frame.contentDocument.createElement('img'); + image.onload = () => {{ imageResult = 'load'; }}; + image.onerror = () => {{ imageResult = 'error'; }}; + image.src = '{}{path}'; + frame.contentDocument.body.append(image); + "#, server.origin)).unwrap(); + let expected = if cross && !report_only { + "error" + } else { + "load" + }; + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "imageResult", + expected, + "redirected image CSP result", + ) + .await; + vm.drain_ready_page_task_executor_turns_for_setup(&loader, 32) + .await + .unwrap(); + drain_pre_domcontentloaded_non_script_page_tasks_for_test(&mut vm); + assert_eq!( + vm.eval("JSON.stringify(events)").unwrap(), + if cross { + let disposition = if report_only { "report" } else { "enforce" }; + serde_json::json!([format!( + "{disposition}|img-src|about|{}/pixel.gif", + server.cross_origin + )]) + .to_string() + } else { + "[]".to_owned() + } + ); + // This is a response check: both HTTP hops complete, but a forbidden + // final response must never be published as a successful image. + assert_eq!(*server.requests.lock(), [path, "/pixel.gif"]); + } +} + +#[tokio::test] +async fn image_csp_header_self_survives_nested_local_documents_and_base_changes() { + for srcdoc in [false, true] { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let document_url = "https://image-csp-creator.test/page.html"; + let mut vm = new_storage_page_task_executor_test_vm_with_loader(document_url, &loader); + set_header_policy(&mut vm, document_url, "img-src 'self'", false); + loaded_child( + &mut vm, + &loader, + if srcdoc { + "frame.srcdoc = '';" + } else { + "frame.src = 'about:blank';" + }, + ) + .await; + vm.eval( + r#" + const base = frame.contentDocument.createElement('base'); + base.href = 'https://unrelated-base.test/'; + frame.contentDocument.head.append(base); + globalThis.nestedLoaded = false; + globalThis.nested = frame.contentDocument.createElement('iframe'); + nested.onload = () => { nestedLoaded = true; }; + nested.srcdoc = ''; + frame.contentDocument.body.append(nested); + "#, + ) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(nestedLoaded)", + "true", + "nested local document must load", + ) + .await; + vm.eval( + r#" + globalThis.events = []; + nested.contentWindow.addEventListener('securitypolicyviolation', event => { + events.push(event.effectiveDirective + ':' + event.documentURI); + }); + for (const [name, url] of [ + ['self', 'https://image-csp-creator.test/pixel.gif'], + ['base', 'https://unrelated-base.test/pixel.gif'], + ]) { + const image = nested.contentDocument.createElement('img'); + image.onload = () => events.push(name + ':load'); + image.onerror = () => events.push(name + ':error'); + image.src = url; + nested.contentDocument.body.append(image); + } + "#, + ) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(events.filter(x => /:(load|error)$/.test(x)).length)", + "2", + "nested image terminals", + ) + .await; + vm.drain_ready_page_task_executor_turns_for_setup(&loader, 32) + .await + .unwrap(); + drain_pre_domcontentloaded_non_script_page_tasks_for_test(&mut vm); + assert_eq!( + vm.eval("events.sort().join('|')").unwrap(), + "base:error|img-src:about|self:load" + ); + } +} + +#[tokio::test] +async fn image_csp_network_child_uses_its_own_policy_source_after_navigation() { + let server = ImageCspServer::spawn().await; + let loader = server.loader(); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + &format!("{}/page.html", server.origin), + &loader, + ); + vm.eval( + "globalThis.events = []; addEventListener('message', event => events.push(event.data));", + ) + .unwrap(); + loaded_child( + &mut vm, + &loader, + &format!("frame.src = '{}/child.html';", server.cross_origin), + ) + .await; + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(events.filter(x => /:(load|error)$/.test(x)).length)", + "2", + "network child image terminals", + ) + .await; + vm.drain_ready_page_task_executor_turns_for_setup(&loader, 32) + .await + .unwrap(); + drain_pre_domcontentloaded_non_script_page_tasks_for_test(&mut vm); + assert_eq!( + vm.eval("events.sort().join('|')").unwrap(), + format!("child:load|{}/child.html|parent:error", server.cross_origin) + ); + assert_eq!(*server.requests.lock(), ["/child.html", "/pixel.gif"]); +} + +#[tokio::test] +async fn css_image_csp_checks_owner_before_network_and_keeps_report_only_loads() { + for report_only in [false, true] { + let server = ImageCspServer::spawn().await; + let loader = server.loader(); + let document_url = format!("{}/page.html", server.origin); + let mut vm = new_storage_page_task_executor_test_vm_with_loader(&document_url, &loader); + vm.set_layout_policy(crate::real_layout_test_policy()); + set_header_policy(&mut vm, &document_url, "img-src 'none'", report_only); + loaded_child(&mut vm, &loader, "frame.srcdoc = '';").await; + vm.eval(&format!(r#" + globalThis.events = []; + addEventListener('securitypolicyviolation', () => events.push('parent-report')); + frame.contentWindow.addEventListener('securitypolicyviolation', event => {{ + events.push(event.disposition + ':' + event.documentURI + ':' + event.effectiveDirective); + }}); + frame.contentDocument.body.innerHTML = '
'; + "#, server.origin)).unwrap(); + vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(80, 60, 1.0)) + .unwrap() + .expect("CSS image discovery layout"); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(events.length)", + "1", + "CSS image owner CSP report", + ) + .await; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8); + let completion = vm.css_image_completion_notify_for_test(); + loop { + let notified = completion.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + let (pending, decoding, _, _, _) = vm.css_image_resource_observability_for_test(); + if pending + decoding == 0 { + break; + } + if pending == 0 { + tokio::time::timeout_at(deadline, notified) + .await + .expect("CSS image decode should complete"); + } else { + wait_for_one_selected_page_task_executor_test_turn_until( + &mut vm, &loader, deadline, + ) + .await + .unwrap(); + } + } + assert_eq!( + vm.eval("events.join('|')").unwrap(), + if report_only { + "report:about:img-src" + } else { + "enforce:about:img-src" + } + ); + if report_only { + assert_eq!(*server.requests.lock(), ["/pixel.gif"]); + assert_eq!(vm.css_image_resource_observability_for_test().2, 1); + } else { + assert!(server.requests.lock().is_empty()); + assert_eq!(vm.css_image_resource_observability_for_test().2, 0); + } + } +} + +#[tokio::test] +async fn image_csp_rechecks_cached_redirect_response_for_the_requesting_document() { + for report_only in [false, true] { + let server = ImageCspServer::spawn().await; + let loader = server.loader(); + let document_url = format!("{}/page.html", server.origin); + let mut vm = new_storage_page_task_executor_test_vm_with_loader(&document_url, &loader); + vm.eval(&format!( + r#" + globalThis.warmResult = 'pending'; + globalThis.warmImage = new Image(); + warmImage.onload = () => {{ warmResult = 'load'; }}; + warmImage.onerror = () => {{ warmResult = 'error'; }}; + warmImage.src = '{}/allowed/redirect-cross'; + document.body.append(warmImage); + "#, + server.origin + )) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "warmResult", + "load", + "unrestricted redirect must warm the image cache", + ) + .await; + set_header_policy(&mut vm, &document_url, "img-src 'self'", report_only); + loaded_child(&mut vm, &loader, "frame.srcdoc = '';").await; + vm.eval(&format!( + r#" + globalThis.events = []; + frame.contentWindow.addEventListener('securitypolicyviolation', event => {{ + events.push(event.disposition + '|' + event.documentURI + '|' + event.blockedURI); + }}); + globalThis.imageResult = 'pending'; + const image = frame.contentDocument.createElement('img'); + image.onload = () => {{ imageResult = 'load'; }}; + image.onerror = () => {{ imageResult = 'error'; }}; + image.src = '{}/allowed/redirect-cross'; + frame.contentDocument.body.append(image); + "#, + server.origin + )) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "imageResult", + if report_only { "load" } else { "error" }, + "cached redirect must honor the new owner's policy", + ) + .await; + vm.drain_ready_page_task_executor_turns_for_setup(&loader, 32) + .await + .unwrap(); + drain_pre_domcontentloaded_non_script_page_tasks_for_test(&mut vm); + assert_eq!( + vm.eval("events.join(',')").unwrap(), + format!( + "{}|about|{}/pixel.gif", + if report_only { "report" } else { "enforce" }, + server.cross_origin, + ) + ); + assert_eq!( + *server.requests.lock(), + ["/allowed/redirect-cross", "/pixel.gif"] + ); + } +} + +#[tokio::test] +async fn css_image_csp_checks_cached_redirect_before_shared_ready_reuse() { + let server = ImageCspServer::spawn().await; + let loader = server.loader(); + let document_url = format!("{}/page.html", server.origin); + let mut vm = new_storage_page_task_executor_test_vm_with_loader(&document_url, &loader); + vm.set_layout_policy(crate::real_layout_test_policy()); + vm.eval(&format!( + r#" + globalThis.warmResult = 'pending'; + globalThis.warmImage = new Image(); + warmImage.onload = () => {{ warmResult = 'load'; }}; + warmImage.onerror = () => {{ warmResult = 'error'; }}; + warmImage.src = '{}/allowed/redirect-cross'; + document.body.append(warmImage); + "#, + server.origin + )) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "warmResult", + "load", + "unrestricted image must warm the decoded cache", + ) + .await; + set_header_policy(&mut vm, &document_url, "img-src 'self'", false); + loaded_child(&mut vm, &loader, "frame.srcdoc = '';").await; + vm.eval(&format!(r#" + globalThis.events = []; + frame.contentWindow.addEventListener('securitypolicyviolation', event => {{ + events.push(event.documentURI + '|' + event.blockedURI); + }}); + frame.contentDocument.body.innerHTML = '
'; + "#, server.origin)).unwrap(); + vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(80, 60, 1.0)) + .unwrap() + .expect("cached CSS image discovery layout"); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(events.length)", + "1", + "cached CSS image must be rejected for the new owner", + ) + .await; + assert_eq!( + vm.eval("events.join(',')").unwrap(), + format!("about|{}/pixel.gif", server.cross_origin) + ); + assert_eq!(vm.css_image_resource_observability_for_test().2, 0); + assert_eq!( + *server.requests.lock(), + ["/allowed/redirect-cross", "/pixel.gif"] + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 89208e6ba98..68cb794f6f0 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -7,6 +7,7 @@ mod credential_json_options; mod cross_origin_navigation; mod current_pass_css_fonts; mod document_encodings; +mod image_csp; mod message_port_transfer; mod picture_in_picture_interfaces; mod touch_native_receivers; diff --git a/moli-renderer-v8/src/stylesheet_runtime/settlement.rs b/moli-renderer-v8/src/stylesheet_runtime/settlement.rs index 03654264686..215ecdcac40 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/settlement.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/settlement.rs @@ -324,6 +324,31 @@ impl DocumentRuntime { } } StylesheetLoadBlockingResource::Image(url) => { + let owner = binding.child_handle().map_or( + crate::native_bridge::OwnerDispatchScope::Top, + crate::native_bridge::OwnerDispatchScope::Child, + ); + if !host.stylesheet_subresource_load_delay_is_current(binding) + || host.check_owner_subresource_csp( + scope, + owner, + &url, + crate::document_runtime::DocumentSubresourceCspKind::Image, + crate::content_security_policy::ContentSecurityPolicyRedirectStatus::NoRedirect, + ).blocks_request() + || host.cached_css_image_response_url(&url).is_some_and(|response_url| { + host.check_owner_subresource_csp( + scope, + owner, + &response_url, + crate::document_runtime::DocumentSubresourceCspKind::Image, + crate::content_security_policy::ContentSecurityPolicyRedirectStatus::FollowedRedirect, + ).blocks_request() + }) + { + host.settle_stylesheet_subresource_load_delay(binding); + continue; + } let identity = if retain_css_images { match host.admit_stylesheet_css_image(binding, url.as_str().to_owned()) { crate::native_bridge::CssImageResourceAdmission::Fetch(identity) => {