From 8a1f39d33c0aa016b10886dca7df3590fa3fb1d2 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 8 Sep 2026 16:57:16 +0800 Subject: [PATCH] fix(scripts): enforce client-origin CORS before script execution Bind script loads and cache entries to the document client origin, authorize redirects before following them, and preserve redirect origin taint across Window and Worker Fetch/XHR validation and response filtering. Check dynamic script CSP before transport, cover real HTTP and lifecycle regressions, and refresh the one newly passing WPT case after fixed-binary CLI/CDP comparisons. --- .../wpt-cross-current/failed-cases.txt | 1 - .../wpt-cross-current/passed-cases.txt | 1 + moli-core/tests/scripts.rs | 10 +- moli-fetch/src/blocking/mod.rs | 35 +- .../src/network/context/document.rs | 10 + .../src/network/request_client.rs | 77 ++- .../src/network/request_client/tests.rs | 1 + .../request_client/tests/script_cors.rs | 486 ++++++++++++++++++ moli-renderer-v8/src/network_host.rs | 2 +- .../src/network_host/async_fetch.rs | 77 ++- .../src/network_host/response/cors.rs | 56 ++ moli-renderer-v8/src/planning.rs | 18 + .../src/runtime/page_vm/tests/mod.rs | 147 ++++-- moli-renderer-v8/src/script_vm/tests/mod.rs | 2 +- .../src/worker/global_scope/xhr.rs | 8 +- .../src/worker/thread/tests/cors_redirects.rs | 125 +++++ .../src/worker/thread/tests/mod.rs | 1 + 17 files changed, 1003 insertions(+), 54 deletions(-) create mode 100644 moli-renderer-v8/src/network/request_client/tests/script_cors.rs create mode 100644 moli-renderer-v8/src/worker/thread/tests/cors_redirects.rs diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 823cf4686c..5ec6bd2a34 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -2969,7 +2969,6 @@ html/semantics/scripting-1/the-script-element/json-module/json-module-service-wo html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-window-onerror-module.html html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-window-onerror.html html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-workerglobalscope-onerror.html -html/semantics/scripting-1/the-script-element/module/crossorigin.html html/semantics/scripting-1/the-script-element/module/dynamic-import/alpha/base-url-worker-importScripts.html html/semantics/scripting-1/the-script-element/module/dynamic-import/code-cache-base-url.html html/semantics/scripting-1/the-script-element/module/inline-async-execorder.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 2000d86fb8..8b961b3462 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -6489,6 +6489,7 @@ html/semantics/scripting-1/the-script-element/module/choice-of-error-2.html html/semantics/scripting-1/the-script-element/module/choice-of-error-3.html html/semantics/scripting-1/the-script-element/module/compilation-error-1.html html/semantics/scripting-1/the-script-element/module/compilation-error-2.html +html/semantics/scripting-1/the-script-element/module/crossorigin.html html/semantics/scripting-1/the-script-element/module/currentScript-null.html html/semantics/scripting-1/the-script-element/module/custom-element-exception.html html/semantics/scripting-1/the-script-element/module/duplicated-imports-1.html diff --git a/moli-core/tests/scripts.rs b/moli-core/tests/scripts.rs index a982f05f59..f1a954f91d 100644 --- a/moli-core/tests/scripts.rs +++ b/moli-core/tests/scripts.rs @@ -5269,7 +5269,9 @@ async fn module_wasm_csp_blocks_cross_origin_script_element_fetch() -> Result<() .await?; assert_eq!( diagnostic_global(&page, "moduleWasmCspViolationCount"), - Some(&JsValueSnapshot::Number(1.0)) + Some(&JsValueSnapshot::Number(1.0)), + "script report: {:#?}", + page.script_execution() ); assert_eq!( diagnostic_global(&page, "moduleWasmCspViolationText"), @@ -5281,6 +5283,12 @@ async fn module_wasm_csp_blocks_cross_origin_script_element_fetch() -> Result<() diagnostic_global(&page, "moduleWasmCspExecuted"), Some(&JsValueSnapshot::String(String::new())) ); + assert!( + page.subresource_network_records() + .iter() + .all(|record| record.url().path() != "/assets/execute-start.wasm"), + "CSP must block the dynamic module before source transport starts" + ); server.shutdown().await; Ok(()) diff --git a/moli-fetch/src/blocking/mod.rs b/moli-fetch/src/blocking/mod.rs index f9af52be98..357a845d94 100644 --- a/moli-fetch/src/blocking/mod.rs +++ b/moli-fetch/src/blocking/mod.rs @@ -325,12 +325,41 @@ fn append_browser_subresource_headers( request: &Request, request_url: &Url, ) { - let Some(metadata) = request.browser_request_metadata() else { - return; - }; if !matches!(request_url.scheme(), "http" | "https") { return; } + let Some(metadata) = request.browser_request_metadata() else { + if matches!( + request.resource_type, + crate::RequestResourceType::Script + | crate::RequestResourceType::ParserBlockingScript + | crate::RequestResourceType::ClassicAsyncOrDeferScript + | crate::RequestResourceType::LatePreloadScript + ) { + append_header_if_missing(outgoing, "Accept", "*/*".to_owned()); + append_header_if_missing( + outgoing, + "Accept-Language", + config.browser_identity().accept_language().to_owned(), + ); + append_header_if_missing( + outgoing, + "Sec-Fetch-Site", + request_sec_fetch_site(request, request_url), + ); + append_header_if_missing( + outgoing, + "Sec-Fetch-Mode", + request.request_mode.as_ref().to_owned(), + ); + append_header_if_missing(outgoing, "Sec-Fetch-Dest", "script".to_owned()); + if let Some(origin) = request_origin_header_value(request, request_url) { + append_header_if_missing(outgoing, "Origin", origin); + } + append_browser_client_hints(outgoing, config); + } + return; + }; match metadata { BrowserRequestMetadata::Audio diff --git a/moli-renderer-v8/src/network/context/document.rs b/moli-renderer-v8/src/network/context/document.rs index 6103b5be4c..0a0260910f 100644 --- a/moli-renderer-v8/src/network/context/document.rs +++ b/moli-renderer-v8/src/network/context/document.rs @@ -140,6 +140,11 @@ impl DocumentResourceLoader { request_client = request_client.with_browser_site_context(browser_site_context); } let loads = ResourceLoadRegistry::new(task_runner); + // The Fetch client origin is independent of a script's referrer/base + // URL, including cross-origin dependencies and inherited/sandboxed Documents. + let request_client = request_client.with_script_request_origin( + moli_url::WebOrigin::from_ascii_serialization(context.origin()), + ); Self { request_client, authority: Arc::new(DocumentResourceLoaderAuthority { @@ -213,6 +218,11 @@ impl DocumentResourceLoader { if let Some(browser_site_context) = self.request_client.shared_browser_site_context() { request_client = request_client.with_shared_browser_site_context(browser_site_context); } + request_client = request_client.with_script_request_origin( + moli_url::WebOrigin::from_ascii_serialization( + self.authority.lifecycle.lock().context.origin(), + ), + ); Self { request_client, authority: Arc::clone(&self.authority), diff --git a/moli-renderer-v8/src/network/request_client.rs b/moli-renderer-v8/src/network/request_client.rs index 937b242fc9..90bc19d4fa 100644 --- a/moli-renderer-v8/src/network/request_client.rs +++ b/moli-renderer-v8/src/network/request_client.rs @@ -33,6 +33,7 @@ pub struct ResourceRequestClient { resource_runtime: BrowserResourceRuntime, page_network_policy: PageNetworkPolicy, browser_site_context: Option>, + script_request_origin: Option>, } /// Thread-affine lifetime root for a standalone resource request client. @@ -104,6 +105,22 @@ impl ResourceRequestClient { resource_runtime, page_network_policy, browser_site_context: None, + script_request_origin: None, + } + } + + pub(crate) fn with_script_request_origin(mut self, origin: moli_url::WebOrigin) -> Self { + self.script_request_origin = Some(Arc::new(origin)); + self + } + + fn script_request_with_client_origin(&self, request: Request) -> Request { + if request.request_origin().is_none() + && let Some(origin) = &self.script_request_origin + { + request.with_request_origin(origin.as_ref().clone()) + } else { + request } } @@ -141,6 +158,7 @@ impl ResourceRequestClient { self.page_network_policy.frozen_request_view(), ); client.browser_site_context = self.browser_site_context.clone(); + client.script_request_origin = self.script_request_origin.clone(); client } @@ -281,7 +299,7 @@ impl ResourceRequestClient { &self, request: Request, ) -> Result { - let request = self.apply_network_policy(request)?; + let request = self.apply_network_policy(self.script_request_with_client_origin(request))?; if let Some(result) = local_text_response(&request.url) { return result; } @@ -299,7 +317,10 @@ impl ResourceRequestClient { ); } let result = self - .fetch_text_stream_with_cancel_after_policy(request, FetchCancelHandle::new()) + .fetch_script_text_stream_with_cancel_after_policy( + request, + FetchCancelHandle::new(), + ) .await; if let (Some(started), Some(url)) = (started, timing_url.as_deref()) { let status = result.as_ref().ok().map(|response| response.status); @@ -400,7 +421,7 @@ impl ResourceRequestClient { } let cache_request = request.clone(); let result = self - .fetch_text_stream_with_cancel_after_policy(request, FetchCancelHandle::new()) + .fetch_script_text_stream_with_cancel_after_policy(request, FetchCancelHandle::new()) .await .map_err(|error| format!("{error:#}")); if let (Some(started), Some(url)) = (started, timing_url.as_deref()) { @@ -445,7 +466,7 @@ impl ResourceRequestClient { where F: FnOnce(Result) + Send + 'static, { - let request = self.apply_network_policy(request)?; + let request = self.apply_network_policy(self.script_request_with_client_origin(request))?; if let Some(result) = local_text_response(&request.url) { let task_runner = resource_load.task_runner(); task_runner.spawn(async move { @@ -470,9 +491,10 @@ impl ResourceRequestClient { let cancel_handle = FetchCancelHandle::new(); resource_load.attach_cancel_handle(cancel_handle.clone()); let callback_resource_load = resource_load.clone(); - let started_fetch = self.fetch_text_callback_with_cancel_after_policy( + let started_fetch = self.fetch_script_text_callback_with_cancel_after_policy( request, cancel_handle, + resource_load.task_runner(), move |result| { callback_resource_load.finish(); if let (Some(started), Some(url)) = (started, timing_url.as_deref()) { @@ -601,9 +623,10 @@ impl ResourceRequestClient { let callback_key = key.clone(); let cancel_handle = FetchCancelHandle::new(); load.attach_transport_cancel(cancel_handle.clone()); - if let Err(error) = self.fetch_text_callback_with_cancel_after_policy( + if let Err(error) = self.fetch_script_text_callback_with_cancel_after_policy( request, cancel_handle, + resource_load.task_runner(), move |result| { let result = result.map_err(|error| format!("{error:#}")); request_client @@ -629,6 +652,48 @@ impl ResourceRequestClient { Ok(()) } + async fn fetch_script_text_stream_with_cancel_after_policy( + &self, + request: Request, + cancel_handle: FetchCancelHandle, + ) -> Result { + if request.request_mode == moli_fetch::RequestMode::Cors + && request.request_origin().is_some() + { + return crate::network_host::fetch_cors_script_text(self, request, cancel_handle) + .await + .map_err(anyhow::Error::msg); + } + self.fetch_text_stream_with_cancel_after_policy(request, cancel_handle) + .await + } + + fn fetch_script_text_callback_with_cancel_after_policy( + &self, + request: Request, + cancel_handle: FetchCancelHandle, + task_runner: super::RendererResourceTaskRunner, + callback: F, + ) -> Result<()> + where + F: FnOnce(Result) + Send + 'static, + { + if request.request_mode == moli_fetch::RequestMode::Cors + && request.request_origin().is_some() + { + let client = self.clone(); + task_runner.spawn(async move { + callback( + client + .fetch_script_text_stream_with_cancel_after_policy(request, cancel_handle) + .await, + ); + }); + return Ok(()); + } + self.fetch_text_callback_with_cancel_after_policy(request, cancel_handle, callback) + } + async fn fetch_text_stream_with_cancel_after_policy( &self, request: Request, diff --git a/moli-renderer-v8/src/network/request_client/tests.rs b/moli-renderer-v8/src/network/request_client/tests.rs index 2f076e64ed..41fa61ee8c 100644 --- a/moli-renderer-v8/src/network/request_client/tests.rs +++ b/moli-renderer-v8/src/network/request_client/tests.rs @@ -50,6 +50,7 @@ fn browser_client_rejects_http_requests_even_with_an_initiator() -> Result<()> { owner.handle().apply_network_policy(browser)?; Ok(()) } +mod script_cors; #[test] fn loader_clones_share_one_browser_resource_runtime() { diff --git a/moli-renderer-v8/src/network/request_client/tests/script_cors.rs b/moli-renderer-v8/src/network/request_client/tests/script_cors.rs new file mode 100644 index 0000000000..2e8348a206 --- /dev/null +++ b/moli-renderer-v8/src/network/request_client/tests/script_cors.rs @@ -0,0 +1,486 @@ +use super::*; +use crate::{ + frame_owner_model::{DocumentId, FrameDocumentTaskOwner, FrameSchedulerLaneId, LocalWindowId}, + native_bridge::WindowDocumentOwner, + network::{ + RendererResourceTaskRunner, + context::{DocumentFetchContext, DocumentResourceLoader}, + loads::{ResourceLoadDisposition, ResourceLoadKind}, + }, +}; +use moli_fetch::{RequestMode, Response}; +use parking_lot::Mutex; + +struct ScriptServer { + url: Url, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl ScriptServer { + async fn new(status: u16, headers: &[(&str, &str)]) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = Url::parse(&format!( + "http://{}/script.mjs", + listener.local_addr().unwrap() + )) + .unwrap(); + let body = "export const value = 1;"; + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status} Test\r\nContent-Type: application/javascript\r\nCache-Control: max-age=3600\r\nContent-Length: {}\r\nConnection: close\r\n{extra_headers}\r\n{body}", + body.len() + ); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let task = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let request = read_http_request_text(&mut stream).await.unwrap(); + captured.lock().push(request); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + Self { + url, + requests, + task, + } + } + + fn request_count(&self) -> usize { + self.requests.lock().len() + } + + fn last_origin(&self) -> Option { + self.last_header("origin") + } + + fn last_header(&self, header: &str) -> Option { + self.requests.lock().last().and_then(|request| { + request.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case(header) + .then(|| value.trim().to_owned()) + }) + }) + } +} + +impl Drop for ScriptServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn document(loader: &ResourceRequestClient, url: &str, origin: &str) -> DocumentResourceLoader { + let url = Url::parse(url).unwrap(); + DocumentResourceLoader::new( + loader.clone(), + RendererResourceTaskRunner::from_current_tokio().unwrap(), + DocumentFetchContext::new( + WindowDocumentOwner::Frame(FrameDocumentTaskOwner::new( + FrameSchedulerLaneId(7), + LocalWindowId(11), + DocumentId(1), + )), + url.clone(), + url, + origin, + ), + ) +} + +fn script_request(url: &Url, mode: RequestMode, credentials: RequestCredentialsMode) -> Request { + Request::new("GET", url.as_str(), None, Vec::new()) + .unwrap() + // A dependency's referrer can be same-origin with the script server. + // It must never replace the Document's origin for CORS or cookies. + .with_initiator_url(url) + .with_request_mode(mode) + .with_credentials_mode(credentials) + .with_script_fetch_metadata(ScriptFetchRequestMetadata::default()) +} + +async fn fetch_script( + document: &DocumentResourceLoader, + request: Request, + callback: bool, +) -> Result { + let load = document + .register_load( + ResourceLoadKind::Script, + ResourceLoadDisposition::Ordinary, + None, + ) + .unwrap(); + let client = load.request_client(); + if callback { + let (tx, rx) = oneshot::channel(); + client.fetch_cacheable_script_text_callback_with_load(request, load, move |result| { + let _ = tx.send(result); + })?; + timeout(Duration::from_secs(5), rx).await?? + } else { + let result = timeout( + Duration::from_secs(5), + client.fetch_cacheable_script_text_stream(request), + ) + .await?; + load.finish(); + result + } +} + +#[tokio::test] +async fn script_cors_checks_the_document_origin_and_credentials_in_both_fetch_paths() { + for callback in [false, true] { + for (headers, credentials, allowed) in [ + (vec![], RequestCredentialsMode::SameOrigin, false), + ( + vec![("Access-Control-Allow-Origin", "https://wrong.test")], + RequestCredentialsMode::SameOrigin, + false, + ), + ( + vec![("Access-Control-Allow-Origin", "https://page.test")], + RequestCredentialsMode::SameOrigin, + true, + ), + ( + vec![("Access-Control-Allow-Origin", "*")], + RequestCredentialsMode::SameOrigin, + true, + ), + ( + vec![("Access-Control-Allow-Origin", "*")], + RequestCredentialsMode::Include, + false, + ), + ( + vec![("Access-Control-Allow-Origin", "https://page.test")], + RequestCredentialsMode::Include, + false, + ), + ( + vec![ + ("Access-Control-Allow-Origin", "https://page.test"), + ("Access-Control-Allow-Credentials", "true"), + ], + RequestCredentialsMode::Include, + true, + ), + ] { + let server = ScriptServer::new(200, &headers).await; + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let document = document(&transport, "https://page.test/page", "https://page.test"); + let result = fetch_script( + &document, + script_request(&server.url, RequestMode::Cors, credentials), + callback, + ) + .await; + assert_eq!( + result.is_ok(), + allowed, + "callback={callback}, headers={headers:?}, credentials={credentials:?}: {result:?}" + ); + assert_eq!(server.last_origin().as_deref(), Some("https://page.test")); + if let Err(error) = result { + assert!(error.to_string().contains("CORS check failed"), "{error}"); + } + } + } +} + +#[tokio::test] +async fn script_cors_preserves_same_origin_and_no_cors_loading() { + for callback in [false, true] { + for mode in [RequestMode::Cors, RequestMode::NoCors] { + let server = ScriptServer::new(200, &[]).await; + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let origin = if mode == RequestMode::Cors { + moli_url::origin_ascii_serialization(&server.url) + } else { + "https://page.test".to_owned() + }; + let document = document(&transport, server.url.as_str(), &origin); + fetch_script( + &document, + script_request(&server.url, mode, RequestCredentialsMode::Include), + callback, + ) + .await + .unwrap(); + } + } +} + +#[tokio::test] +async fn script_cors_cache_cannot_reuse_no_cors_or_another_documents_authorization() { + for callback in [false, true] { + let server = ScriptServer::new( + 200, + &[ + ("Access-Control-Allow-Origin", "https://first.test"), + ("Access-Control-Allow-Credentials", "true"), + ], + ) + .await; + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let first = document(&transport, "https://first.test/page", "https://first.test"); + let second = first.fork_for_document(DocumentFetchContext::new( + WindowDocumentOwner::Frame(FrameDocumentTaskOwner::new( + FrameSchedulerLaneId(7), + LocalWindowId(11), + DocumentId(2), + )), + Url::parse("https://second.test/page").unwrap(), + Url::parse("https://second.test/base").unwrap(), + "https://second.test", + )); + for _ in 0..2 { + fetch_script( + &first, + script_request( + &server.url, + RequestMode::Cors, + RequestCredentialsMode::Include, + ), + callback, + ) + .await + .unwrap(); + } + assert_eq!( + server.request_count(), + 1, + "same Document should reuse its authorized response" + ); + let result = fetch_script( + &second, + script_request( + &server.url, + RequestMode::Cors, + RequestCredentialsMode::Include, + ), + callback, + ) + .await; + assert!( + result.is_err(), + "another Document must perform its own CORS check" + ); + + let server = ScriptServer::new(200, &[]).await; + fetch_script( + &first, + script_request( + &server.url, + RequestMode::NoCors, + RequestCredentialsMode::Include, + ), + callback, + ) + .await + .unwrap(); + let result = fetch_script( + &first, + script_request( + &server.url, + RequestMode::Cors, + RequestCredentialsMode::Include, + ), + callback, + ) + .await; + assert!( + result.is_err(), + "a classic no-cors cache entry must not authorize a module" + ); + } +} + +#[tokio::test] +async fn script_cors_rejects_a_redirect_before_requesting_its_target() { + for callback in [false, true] { + let final_server = ScriptServer::new(200, &[("Access-Control-Allow-Origin", "*")]).await; + let redirect = ScriptServer::new(302, &[("Location", final_server.url.as_str())]).await; + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let document = document(&transport, "https://page.test/page", "https://page.test"); + let result = fetch_script( + &document, + script_request( + &redirect.url, + RequestMode::Cors, + RequestCredentialsMode::SameOrigin, + ), + callback, + ) + .await; + assert!( + result.is_err(), + "missing CORS headers on an intermediate redirect must reject" + ); + assert_eq!( + final_server.request_count(), + 0, + "blocked redirect target must not be fetched" + ); + } +} + +#[tokio::test] +async fn script_cors_cross_origin_redirects_taint_the_origin() { + for callback in [false, true] { + for (allow_origin, allowed) in [("null", true), ("https://page.test", false)] { + let final_server = + ScriptServer::new(200, &[("Access-Control-Allow-Origin", allow_origin)]).await; + let redirect = ScriptServer::new( + 302, + &[ + ("Location", final_server.url.as_str()), + ("Access-Control-Allow-Origin", "https://page.test"), + ], + ) + .await; + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let document = document(&transport, "https://page.test/page", "https://page.test"); + let result = fetch_script( + &document, + script_request( + &redirect.url, + RequestMode::Cors, + RequestCredentialsMode::SameOrigin, + ), + callback, + ) + .await; + assert_eq!(result.is_ok(), allowed, "{result:?}"); + assert_eq!(redirect.last_origin().as_deref(), Some("https://page.test")); + assert_eq!(final_server.last_origin().as_deref(), Some("null")); + } + } +} + +#[tokio::test] +async fn script_cors_redirects_drop_authorization_apply_referrer_policy_and_preserve_fragments() { + for callback in [false, true] { + let final_server = ScriptServer::new(200, &[("Access-Control-Allow-Origin", "*")]).await; + let redirect = ScriptServer::new( + 302, + &[ + ("Location", final_server.url.as_str()), + ("Referrer-Policy", "no-referrer"), + ], + ) + .await; + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let origin = moli_url::origin_ascii_serialization(&redirect.url); + let document = document(&transport, redirect.url.as_str(), &origin); + let mut request = script_request( + &redirect.url, + RequestMode::Cors, + RequestCredentialsMode::SameOrigin, + ); + request.url.set_fragment(Some("module-fragment")); + request.request_headers = vec![ + ("Authorization".to_owned(), "Bearer test-only".to_owned()), + ("X-Embedder".to_owned(), "preserved".to_owned()), + ]; + let response = fetch_script(&document, request, callback).await.unwrap(); + assert_eq!(response.final_url.fragment(), Some("module-fragment")); + assert_eq!(redirect.request_count(), 1); + assert_eq!( + final_server.request_count(), + 1, + "browser-added script headers must not trigger preflight" + ); + assert_eq!( + redirect.last_header("authorization").as_deref(), + Some("Bearer test-only") + ); + assert_eq!(final_server.last_header("authorization"), None); + assert_eq!(final_server.last_header("referer"), None); + assert_eq!( + final_server.last_header("x-embedder").as_deref(), + Some("preserved") + ); + } +} + +#[tokio::test] +async fn script_cors_redirects_reject_non_http_schemes_and_cross_origin_url_credentials() { + for callback in [false, true] { + for target in [ + "data:text/javascript,export default 1", + "http://user:pass@other.test/module.js", + ] { + let redirect = ScriptServer::new(302, &[("Location", target)]).await; + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let origin = moli_url::origin_ascii_serialization(&redirect.url); + let document = document(&transport, redirect.url.as_str(), &origin); + let result = fetch_script( + &document, + script_request( + &redirect.url, + RequestMode::Cors, + RequestCredentialsMode::SameOrigin, + ), + callback, + ) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("CORS redirect")); + } + } +} + +#[tokio::test] +async fn script_cors_uses_inherited_and_opaque_document_origins_after_transport_replacement() { + for callback in [false, true] { + for origin in ["https://creator.test", "null"] { + let server = ScriptServer::new(200, &[("Access-Control-Allow-Origin", origin)]).await; + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let replacement = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let document = document(&transport, "about:srcdoc", origin) + .with_replacement_transport(replacement.handle()); + fetch_script( + &document, + script_request( + &server.url, + RequestMode::Cors, + RequestCredentialsMode::SameOrigin, + ), + callback, + ) + .await + .unwrap(); + assert_eq!(server.last_origin().as_deref(), Some(origin)); + } + } +} + +#[test] +fn script_cors_memory_cache_key_separates_origins_and_request_modes() { + let url = Url::parse("https://script.test/module.js").unwrap(); + let first = script_request(&url, RequestMode::Cors, RequestCredentialsMode::Include) + .with_request_origin(moli_url::WebOrigin::from_ascii_serialization( + "https://first.test", + )); + let second = first + .clone() + .with_request_origin(moli_url::WebOrigin::from_ascii_serialization( + "https://second.test", + )); + assert_ne!( + super::super::script_text_cache_key(&first), + super::super::script_text_cache_key(&second) + ); + assert_ne!( + super::super::script_text_cache_key(&first), + super::super::script_text_cache_key(&first.clone().with_request_mode(RequestMode::NoCors)) + ); +} diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index 40ea8bf66a..664801ee35 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -32,7 +32,7 @@ pub(crate) use self::async_fetch::{ fetch_browser_subresource_raw_stream_with_preflight_headers_and_network_metadata, fetch_browser_subresource_with_preflight_and_network_metadata, fetch_browser_subresource_with_preflight_headers, - fetch_browser_subresource_with_preflight_headers_and_network_metadata, + fetch_browser_subresource_with_preflight_headers_and_network_metadata, fetch_cors_script_text, spawn_async_subresource_fetch, }; pub(crate) use self::beacon::{navigator_send_beacon_callback, send_link_audit_ping}; diff --git a/moli-renderer-v8/src/network_host/async_fetch.rs b/moli-renderer-v8/src/network_host/async_fetch.rs index f2b193365e..0ca71b58b9 100644 --- a/moli-renderer-v8/src/network_host/async_fetch.rs +++ b/moli-renderer-v8/src/network_host/async_fetch.rs @@ -7,6 +7,28 @@ use moli_fetch::{ const MAX_MANUAL_CORS_REDIRECTS: usize = 20; +/// Script fetches need CORS authorization before following every redirect, +/// even though their browser-generated GET requests do not require preflight. +pub(crate) async fn fetch_cors_script_text( + loader: &ResourceRequestClient, + request: Request, + cancel_handle: FetchCancelHandle, +) -> Result { + let observed = fetch_browser_subresource_raw_stream_with_manual_preflight_redirects( + loader, + request, + Some(cancel_handle), + Vec::new(), + None, + ) + .await?; + observed + .into_response() + .into_lossy_materialized_text_response() + .await + .map_err(format_network_error) +} + #[cfg(test)] pub(crate) async fn fetch_browser_subresource_with_preflight( loader: ResourceRequestClient, @@ -200,7 +222,46 @@ impl ManualCorsRedirectState { unreachable!("redirect modes were handled before the follow transition") } } + if !matches!(next_url.scheme(), "http" | "https") { + return Err(format!("CORS redirect requires an HTTP(S) URL: {next_url}")); + } + if self.request.request_mode == RequestMode::Cors + && (!next_url.username().is_empty() || next_url.password().is_some()) + && self.request.request_origin().is_some_and(|origin| { + !origin.same_origin_url(&head.final_url) || !origin.same_origin_url(&next_url) + }) + { + return Err("CORS redirect URL must not include credentials".to_owned()); + } + if !moli_url::same_origin(&head.final_url, &next_url) { + self.request + .request_headers + .retain(|(name, _)| !name.eq_ignore_ascii_case("authorization")); + if self + .request + .auth() + .is_some_and(|auth| auth.target == moli_fetch::RequestAuthTarget::Server) + { + self.request.set_auth(None); + } + } + if let Some(policy) = + crate::referrer_policy::response_referrer_policy_from_headers(&head.headers) + { + let mut metadata = self + .request + .subresource_request_metadata() + .cloned() + .unwrap_or_default(); + metadata.referrer_policy = Some(policy); + self.request = self + .request + .clone() + .with_subresource_request_metadata(metadata); + } let redirect_status = head.status; + // Keep the original client origin for Fetch Metadata. Recording the hop + // makes Request origin serialization and CORS validation apply redirect taint. self.request.record_redirect(RedirectInfo { source: moli_fetch::RedirectSource::Network, from_url: head.final_url, @@ -218,7 +279,14 @@ impl ManualCorsRedirectState { }); self.request.apply_redirect_status(redirect_status); self.request.url = next_url; - self.preflight_request_headers = self.request.request_headers.clone(); + // Only author headers participate in preflight. In particular, a + // redirected script must not acquire embedder/browser-added headers here. + self.preflight_request_headers.retain(|(name, _)| { + self.request + .request_headers + .iter() + .any(|(remaining, _)| name.eq_ignore_ascii_case(remaining)) + }); Ok(ManualCorsRedirectTransition::FollowedRedirect) } @@ -340,7 +408,12 @@ fn next_redirect_url( final_url .join(location) .or_else(|_| url::Url::parse(location)) - .map(Some) + .map(|mut url| { + if !location.contains('#') { + url.set_fragment(final_url.fragment()); + } + Some(url) + }) .map_err(|error| { format!("failed to resolve redirect location `{location}` from {final_url}: {error}") }) diff --git a/moli-renderer-v8/src/network_host/response/cors.rs b/moli-renderer-v8/src/network_host/response/cors.rs index 5e01d2f56a..5a5148c79b 100644 --- a/moli-renderer-v8/src/network_host/response/cors.rs +++ b/moli-renderer-v8/src/network_host/response/cors.rs @@ -586,6 +586,62 @@ mod tests { } } + #[test] + fn cors_response_validation_uses_the_redirect_tainted_origin() { + let home = url("https://page.test/a"); + let away = url("https://script.test/b"); + let elsewhere = url("https://other.test/c"); + let origin = WebOrigin::from_url(&home); + for (redirects, serialized_origin) in [ + (vec![(&home, &away)], origin.ascii_serialization()), + (vec![(&away, &elsewhere)], "null"), + (vec![(&home, &away), (&away, &home)], "null"), + ] { + let final_url = redirects.last().expect("redirect target").1.clone(); + let mut head = header_response( + final_url, + vec![( + "Access-Control-Allow-Origin".to_owned(), + serialized_origin.to_owned(), + )], + ); + head.redirected = true; + head.redirect_chain = redirects + .into_iter() + .map(|(from, to)| moli_fetch::RedirectInfo { + source: RedirectSource::Network, + from_url: from.clone(), + to_url: to.clone(), + status: 302, + headers: vec![( + "Access-Control-Allow-Origin".to_owned(), + origin.ascii_serialization().to_owned(), + )], + network_extra_info_available: false, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: false, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }) + .collect(); + assert_eq!( + head.url_list().serialized_origin(&origin), + serialized_origin + ); + validate_cors_response_chain(&origin, &head, RequestCredentialsMode::SameOrigin) + .expect("each response must be authorized against its own request hop origin"); + head.headers.clear(); + assert!( + validate_cors_response_chain(&origin, &head, RequestCredentialsMode::SameOrigin) + .is_err(), + "returning to the initial origin must not bypass CORS" + ); + } + } + #[test] fn cors_preflight_request_headers_detect_unsafe_method_and_headers() { let headers = vec![ diff --git a/moli-renderer-v8/src/planning.rs b/moli-renderer-v8/src/planning.rs index 9fdbef591e..16b25feea4 100644 --- a/moli-renderer-v8/src/planning.rs +++ b/moli-renderer-v8/src/planning.rs @@ -1250,6 +1250,24 @@ mod tests { ); } + #[test] + fn classic_cors_script_uses_the_crossorigin_credentials_mode() { + for (cross_origin, expected) in [ + ("anonymous", RequestCredentialsMode::SameOrigin), + ("", RequestCredentialsMode::SameOrigin), + ("use-credentials", RequestCredentialsMode::Include), + ] { + let metadata = ScriptFetchMetadata { + cross_origin: Some(cross_origin.to_owned()), + ..ScriptFetchMetadata::default() + }; + assert_eq!( + external_script_credentials_mode(ScriptKind::Classic, &metadata), + expected + ); + } + } + #[test] fn external_script_request_sets_browser_fetch_mode() { let classic = diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index 90c825e6fe..9d5aca0604 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -77,6 +77,7 @@ use moli_websocket::test_support::{ use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; +use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio::time::sleep; use url::Url; @@ -4711,22 +4712,9 @@ async fn page_vm_child_defer_classic_source_failure_releases_parser_order_slot() #[tokio::test] async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module() { run_page_vm_async_test(async move { - let (base_url, server) = spawn_path_response_http_server(vec![ - ( - "/moved-child-defer.js", - "HTTP/1.1 200 OK", - "parent.__movedChildDeferEvents.push('classic-ran');".to_owned(), - Duration::from_millis(80), - ), - ( - "/later-moved-module.js", - "HTTP/1.1 200 OK", - "parent.__movedChildDeferEvents.push('module-ran');".to_owned(), - Duration::ZERO, - ), - ]) - .await; - let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let (base_url, release_classic, server) = spawn_moved_child_defer_http_server().await; + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); let document_url = Url::parse(&format!("{base_url}/page")).expect("page url"); let page_vm = test_page_vm_with_loader_and_document_url(&loader, Vec::new(), document_url); let local_executor = page_vm.local_executor.clone(); @@ -4771,7 +4759,10 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module() ))?; for _ in 0..12 { - let Some(_) = page_vm.run_next_child_frame_task_source_for_semantic_test().await else { + let Some(_) = page_vm + .run_next_child_frame_task_source_for_semantic_test() + .await + else { break; }; } @@ -4782,8 +4773,13 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module() ); let mut classic_completion = None; + let mut module_completion = false; + let mut release_classic = Some(release_classic); for _ in 0..4 { - if !page_vm.page_resource_completion_queue().has_ready_completion() { + if !page_vm + .page_resource_completion_queue() + .has_ready_completion() + { tokio::time::timeout( Duration::from_secs(2), wait_for_typed_page_resource_completion(&mut page_vm), @@ -4791,30 +4787,44 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module() .await .expect("moved child defer completion should arrive"); } - let completion = - run_next_resource_completion_as_typed_page_turn(&mut page_vm)?; + let completion = run_next_resource_completion_as_typed_page_turn(&mut page_vm)?; if matches!( completion.action.source(), RendererOwnerResourceActivitySource::ChildClassicScript ) { classic_completion = Some(completion); + } else { + assert!( + matches!( + completion.action.source(), + RendererOwnerResourceActivitySource::ModuleGraphFetch + ), + "the only other completion is the later module root" + ); + run_expected_child_module_script_terminal_turn( + &mut page_vm, + "module terminal retained behind the moved classic defer", + ) + .await; + module_completion = true; + // Make the later module ready while the classic source + // is still pending, without relying on transport timing. + release_classic + .take() + .expect("one module completion") + .send(()) + .expect("classic response should still be gated"); + } + if classic_completion.is_some() && module_completion { break; } - assert!( - matches!( - completion.action.source(), - RendererOwnerResourceActivitySource::ModuleGraphFetch - ), - "the only completion allowed ahead of the moved classic defer is its later module root" - ); - run_expected_child_module_script_terminal_turn( - &mut page_vm, - "module terminal retained behind the moved classic defer", - ) - .await; } classic_completion - .expect("classic source completion must arrive after retained module terminals"); + .expect("classic source completion must arrive before the script is moved"); + assert!( + module_completion, + "later module must be ready before testing defer-slot release" + ); page_vm.vm_mut().eval( r#" (() => { @@ -4862,15 +4872,27 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module() ChildFrameSemanticTurnKind::DocumentLifecycle, "moved defer complete transition", ), - (ChildFrameSemanticTurnKind::HostLoad, "moved defer iframe load"), + ( + ChildFrameSemanticTurnKind::HostLoad, + "moved defer iframe load", + ), ] { sources.push( - run_expected_child_frame_task_source_after_realm_prerequisite_for_wait(&mut page_vm, source, label) - .await, + run_expected_child_frame_task_source_after_realm_prerequisite_for_wait( + &mut page_vm, + source, + label, + ) + .await, ); } let final_events = page_vm.vm_mut().eval("__movedChildDeferEvents.join('|')")?; - assert_eq!(page_vm.run_next_child_frame_task_source_for_semantic_test().await, None); + assert_eq!( + page_vm + .run_next_child_frame_task_source_for_semantic_test() + .await, + None + ); Ok::<_, anyhow::Error>((events_after_dispose, sources, final_events)) }) .await @@ -4891,7 +4913,9 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module() final_events, "before|after|ready:interactive|moved|module-ran|module-load|dcl|ready:complete|load" ); - server.await.expect("moved child defer server should finish"); + server + .await + .expect("moved child defer server should finish"); }) .await; } @@ -14548,6 +14572,53 @@ async fn spawn_path_response_http_server( (format!("http://{addr}"), server) } +async fn spawn_moved_child_defer_http_server() -> (String, oneshot::Sender<()>, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind defer fixture"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let (release_classic, classic_released) = oneshot::channel(); + let task = tokio::spawn(async move { + let mut classic_released = Some(classic_released); + let mut responses = Vec::new(); + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept script request"); + let request = read_http_request_head(&mut stream).await.unwrap(); + let path = request + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + let (body, gate) = match path { + "/moved-child-defer.js" => ( + "parent.__movedChildDeferEvents.push('classic-ran');", + Some(classic_released.take().expect("one classic request")), + ), + "/later-moved-module.js" => { + ("parent.__movedChildDeferEvents.push('module-ran');", None) + } + _ => panic!("unexpected script path: {path}"), + }; + responses.push(tokio::spawn(async move { + if let Some(gate) = gate { + gate.await.expect("test should release classic response"); + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + })); + } + for response in responses { + response.await.expect("script response task"); + } + }); + (base_url, release_classic, task) +} + async fn spawn_concurrent_path_response_http_server( response_specs: Vec<(&'static str, &'static str, String, Duration)>, ) -> (String, JoinHandle<()>) { diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 07723c1faa..4e514e5cce 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -10212,7 +10212,7 @@ async fn spawn_child_external_parser_module_ready_lane_server() -> ( let body = r#"parent.__childExternalParserModuleEvents.push("module:" + (globalThis === self)); globalThis.__childExternalParserModuleValue = 188;"#; let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nAccess-Control-Allow-Origin: http://child-parser-module-driver.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); diff --git a/moli-renderer-v8/src/worker/global_scope/xhr.rs b/moli-renderer-v8/src/worker/global_scope/xhr.rs index 78eb732466..dcaba5ab28 100644 --- a/moli-renderer-v8/src/worker/global_scope/xhr.rs +++ b/moli-renderer-v8/src/worker/global_scope/xhr.rs @@ -644,7 +644,7 @@ fn send_synchronous_worker_xhr( }; match result { - Ok(response) => { + Ok(mut response) => { let response_head = response.head(); let redirect_status = if response_head.redirect_chain.is_empty() { crate::content_security_policy::ContentSecurityPolicyRedirectStatus::NoRedirect @@ -715,6 +715,11 @@ fn send_synchronous_worker_xhr( throw_synchronous_xhr_failure(scope, xhr, &request_url_text, "NetworkError"); return; } + let observable_headers = filter_cors_exposed_response_headers( + &prepared.document_url, + &response_head, + prepared.credentials_mode, + ); record_worker_subresource_success( &state.borrow(), prepared.document_url, @@ -726,6 +731,7 @@ fn send_synchronous_worker_xhr( response_head, SubresourceResponseBody::from_fetch_response(&response), ); + response.headers = observable_headers; apply_xhr_response(scope, xhr, response); } Err(error) => { diff --git a/moli-renderer-v8/src/worker/thread/tests/cors_redirects.rs b/moli-renderer-v8/src/worker/thread/tests/cors_redirects.rs new file mode 100644 index 0000000000..7d1d82d86b --- /dev/null +++ b/moli-renderer-v8/src/worker/thread/tests/cors_redirects.rs @@ -0,0 +1,125 @@ +use super::*; +use parking_lot::Mutex; + +struct CorsServer { + url: String, + requests: Arc>>, + task: JoinHandle<()>, +} + +impl CorsServer { + async fn new(redirect_to: Option) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/data", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let task = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let request = read_http_request_head(&mut stream).await.unwrap(); + let preflight = request.starts_with("OPTIONS "); + captured.lock().push(request); + let origin = if redirect_to.is_some() { + "http://worker.test" + } else { + "null" + }; + let (status, location, body) = if preflight { + ("204 No Content", String::new(), "") + } else if let Some(target) = &redirect_to { + ( + "307 Temporary Redirect", + format!("Location: {target}\r\n"), + "", + ) + } else { + ("200 OK", String::new(), "allowed") + }; + let response = format!( + "HTTP/1.1 {status}\r\nAccess-Control-Allow-Origin: {origin}\r\nAccess-Control-Allow-Methods: PUT\r\nAccess-Control-Allow-Headers: x-test\r\nContent-Type: text/plain\r\nX-Hidden: secret\r\nContent-Length: {}\r\nConnection: close\r\n{location}\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + Self { + url, + requests, + task, + } + } +} + +impl Drop for CorsServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[tokio::test] +async fn worker_cors_redirects_validate_null_origin_in_fetch_and_xhr() { + ensure_v8(); + for api in ["fetch", "xhr", "sync-xhr"] { + let target = CorsServer::new(None).await; + let source = CorsServer::new(Some(target.url.clone())).await; + let loader = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let mut worker = spawn_worker_with_request_client( + format!( + r#" + (async () => {{ + try {{ + const url = {url:?}; + const api = {api:?}; + if (api === "fetch") {{ + const response = await fetch(url, {{ method: "PUT", headers: {{ "X-Test": "1" }} }}); + postMessage([response.status, await response.text(), response.headers.get("X-Hidden")]); + }} else {{ + const xhr = new XMLHttpRequest(); + const done = new Promise(resolve => xhr.onloadend = resolve); + xhr.open(api === "sync-xhr" ? "GET" : "PUT", url, api !== "sync-xhr"); + if (api !== "sync-xhr") xhr.setRequestHeader("X-Test", "1"); + xhr.send(); + if (api !== "sync-xhr") await done; + postMessage([xhr.status, xhr.responseText, xhr.getResponseHeader("X-Hidden")]); + }} + }} catch (error) {{ + postMessage(String(error)); + }} finally {{ close(); }} + }})(); + "#, + url = source.url, + ), + "http://worker.test/main.js".to_owned(), + loader, + ); + let posted = timeout(TIMEOUT, async { + loop { + match worker.recv().await.expect("worker channel") { + WorkerToParentMessage::Post(payload) => break stringify_payload(&payload), + WorkerToParentMessage::SubresourceNetwork(_) => {} + message => panic!("unexpected worker message: {message:?}"), + } + } + }) + .await + .expect("worker CORS redirect result"); + assert_eq!(posted, r#"[200,"allowed",null]"#, "{api}"); + let requests = target.requests.lock(); + let expected_requests = if api == "sync-xhr" { 1 } else { 2 }; + assert_eq!(requests.len(), expected_requests, "{api}"); + assert!( + requests + .last() + .unwrap() + .starts_with(if api == "sync-xhr" { "GET " } else { "PUT " }), + "{api}: actual request must reach the target" + ); + for request in requests.iter() { + assert!( + request + .lines() + .any(|line| line.eq_ignore_ascii_case("origin: null")), + "{api}: {request}" + ); + } + } +} diff --git a/moli-renderer-v8/src/worker/thread/tests/mod.rs b/moli-renderer-v8/src/worker/thread/tests/mod.rs index 205fbce4c2..4864d21651 100644 --- a/moli-renderer-v8/src/worker/thread/tests/mod.rs +++ b/moli-renderer-v8/src/worker/thread/tests/mod.rs @@ -1517,6 +1517,7 @@ fn service_worker_storage_apis_use_explicit_registration_storage_key() { // ─── Basic tests ──────────────────────────────────────────────────── +mod cors_redirects; mod lazy_storage; mod lifecycle; mod modules;