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;