From 9160afc12b6ed206324b67456862717484239386 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 20 Oct 2026 04:46:05 +0800 Subject: [PATCH 1/3] fix(csp): inherit image self sources in local child documents Use the creator URL for source matching in inherited about:blank/srcdoc documents. Keep actual document URLs in violation reports and preserve opaque sandbox origins and data URL blocking. Cover ordinary and sandboxed srcdoc, changed parent/child base URLs, allowed same-origin images, blocked cross-origin images, and owner reports. Regression-commit: 5399724fb07a24935531b92c42621be1baa8c089 Regression-PR: https://github.com/lexmount/moli/pull/1714 Validate this independent patch on main ce0781f2ad7e3ab62198ef169c06ed72d9e4b178: - cargo fmt --all - cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo nextest run --no-fail-fast Summary [ 551.506s] 20434 tests run: 20434 passed (9 slow), 18 skipped Original-commit: 59c8fc213350fd4d00ac06c3ce0eed5a3971a999 --- moli-csp/src/reporting.rs | 3 +- .../context_host/security_policy.rs | 28 ++++++- .../src/script_vm/tests/canvas_webgl.rs | 77 +++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) 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/native_bridge/context_host/security_policy.rs b/moli-renderer-v8/src/native_bridge/context_host/security_policy.rs index 64071829721..69c67522fa8 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 @@ -361,16 +361,40 @@ impl JsContextHost { }; // The element's Document owns this request even when a different // Window's script changes its source or queues the update microtask. + // Local inherited documents use their creator URL for CSP source + // matching, even when sandboxing gives their Window an opaque origin. + // Neither their mutable base URI nor the caller's realm defines 'self'. + let policy_url = match owner { + OwnerDispatchScope::Child(handle) + if moli_url::is_about_blank(&snapshot.document_url) + || moli_url::is_about_srcdoc(&snapshot.document_url) => + { + self.child_browsing_context_secure_context_url(handle) + .unwrap_or_else(|| snapshot.document_url.clone()) + } + _ => snapshot.document_url.clone(), + }; // SAFETY: this host and its DocumentRuntime are owned by the same ScriptVm. - let (report_only_violation, enforced_violation) = unsafe { &*self.runtime } + let (mut report_only_violation, mut enforced_violation) = unsafe { &*self.runtime } .document_subresource_csp_check_for_document( snapshot.document_handle, - &snapshot.document_url, + &policy_url, &snapshot.policy_container, request_url, kind, ) .into_violations(); + if policy_url != snapshot.document_url { + let document_uri = + crate::content_security_policy::csp_url_for_report(&snapshot.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); + } + } let host_ptr: *mut JsContextHost = self; if let Some(violation) = report_only_violation { self.dispatch_content_security_policy_violation_event_for_owner_best_effort( 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 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"] + ); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 7cad614cdb8..31a06c5a1d1 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -6,6 +6,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; From b0f0f85ab7e1d12c6f829728f8ce36e5f01fc605 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 26 Oct 2026 16:46:16 +0800 Subject: [PATCH 3/3] fix(csp): enforce img-src before CSS image admission Apply owner-document image policy before network dispatch or decoded resource reuse. Preserve Report-Only loading and verify owner reports, real HTTP requests, and redirected image cache admission. --- .../context_host/image_resources/mod.rs | 20 +++ .../src/script_vm/tests/image_csp.rs | 127 ++++++++++++++++++ .../src/stylesheet_runtime/settlement.rs | 25 ++++ 3 files changed, 172 insertions(+) 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 225cb7946f1..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 @@ -323,6 +323,26 @@ impl super::JsContextHost { .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) } diff --git a/moli-renderer-v8/src/script_vm/tests/image_csp.rs b/moli-renderer-v8/src/script_vm/tests/image_csp.rs index 94a86b32ffd..72b25aefdf5 100644 --- a/moli-renderer-v8/src/script_vm/tests/image_csp.rs +++ b/moli-renderer-v8/src/script_vm/tests/image_csp.rs @@ -332,6 +332,75 @@ async fn image_csp_network_child_uses_its_own_policy_source_after_navigation() { 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] { @@ -403,3 +472,61 @@ async fn image_csp_rechecks_cached_redirect_response_for_the_requesting_document ); } } + +#[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/stylesheet_runtime/settlement.rs b/moli-renderer-v8/src/stylesheet_runtime/settlement.rs index c414e0fbedc..e43dc0e1354 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/settlement.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/settlement.rs @@ -316,6 +316,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) => {