From b0e803695e7f9554bc6e23f81fedca3cbcc5d55f Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 8 Nov 2026 22:30:18 +0800 Subject: [PATCH] fix(fetch): preserve native Request URL provenance Carry native-versus-converted URL provenance through RequestInfo materialization. Request copies and Window/Worker fetch preserve an intercepted internal URL without reapplying string credential rejection. String and request-like inputs retain their existing resolution and credential validation. Cover native upload copies and real intercepted XHR/Cache requests forwarded through both fetch bindings. --- .../page/tests/same_document_history.rs | 1 + .../native_request_urls.rs | 124 ++++++++++++++++++ moli-renderer-v8/src/network_host.rs | 3 +- .../network_host/fetch/bindings/request.rs | 4 +- .../src/network_host/fetch/input.rs | 2 +- moli-renderer-v8/src/network_host/request.rs | 8 +- .../src/network_host/request/bindings/init.rs | 12 +- .../src/network_host/request/input.rs | 70 +++++++++- .../src/worker/global_scope/fetch.rs | 4 +- .../src/worker/thread/tests/lifecycle.rs | 1 + .../tests/lifecycle/fetch_request_urls.rs | 64 +++++++++ 11 files changed, 265 insertions(+), 28 deletions(-) create mode 100644 moli-protocol/src/domains/page/tests/same_document_history/native_request_urls.rs create mode 100644 moli-renderer-v8/src/worker/thread/tests/lifecycle/fetch_request_urls.rs diff --git a/moli-protocol/src/domains/page/tests/same_document_history.rs b/moli-protocol/src/domains/page/tests/same_document_history.rs index b23a393d33..c80c67c89b 100644 --- a/moli-protocol/src/domains/page/tests/same_document_history.rs +++ b/moli-protocol/src/domains/page/tests/same_document_history.rs @@ -7,6 +7,7 @@ mod cross_origin_navigation; mod frame_attribute_ancestors; mod history_limits; mod hyperlink_navigation; +mod native_request_urls; mod navigation_focus; mod navigation_inspector_output; mod navigation_window_reuse; diff --git a/moli-protocol/src/domains/page/tests/same_document_history/native_request_urls.rs b/moli-protocol/src/domains/page/tests/same_document_history/native_request_urls.rs new file mode 100644 index 0000000000..e5c90e8a23 --- /dev/null +++ b/moli-protocol/src/domains/page/tests/same_document_history/native_request_urls.rs @@ -0,0 +1,124 @@ +use super::*; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +const WORKER: &str = r#" +addEventListener('fetch', event => { + if (new URL(event.request.url).pathname !== '/native-request/echo') return; + event.respondWith((async () => { + const original = event.request, url = original.url; + Object.defineProperty(original, 'url', {get() { throw new Error('public url getter'); }}); + Object.defineProperty(original, 'toString', {value() { throw new Error('public toString'); }}); + const constructors = []; + let copy; + for (const init of [undefined, {}, {cache:'no-store'}]) { + try { + const value = new Request(original, init); + constructors.push({url:value.url}); + copy ||= value; + } catch (e) { constructors.push({error:e.name}); } + } + const forwarded = []; + for (const input of [original, copy]) { + if (!input) { forwarded.push({error:'no copy'}); continue; } + try { + const response = await fetch(input); + forwarded.push({status:response.status, data:await response.json()}); + } catch (e) { forwarded.push({error:e.name}); } + } + const refused = []; + for (const input of [url, {url}]) { + try { await fetch(input); refused.push('allowed'); } catch (e) { refused.push(e.name); } + } + const cache = await caches.open('native-request-url'); + await cache.put(original, new Response('seed')); + return new Response(JSON.stringify({url, constructors, forwarded, refused})); + })()); +}); +"#; + +#[tokio::test(flavor = "multi_thread")] +async fn native_request_urls_are_forwarded_by_window_and_worker_fetch() { + run_history_test(async { + let requests = Arc::new(AtomicUsize::new(0)); + let observed = requests.clone(); + let routes = axum::Router::new() + .route("/request-worker.js", axum::routing::get(|| async { + ([(axum::http::header::CONTENT_TYPE, "application/javascript")], WORKER) + })) + .route("/native-request/frame.html", axum::routing::get(|| async { + ([(axum::http::header::CONTENT_TYPE, "text/html")], "controlled") + })) + .route("/native-request/echo", axum::routing::get(move || { + let requests = observed.clone(); + async move { + requests.fetch_add(1, Ordering::SeqCst); + ([(axum::http::header::CACHE_CONTROL, "no-store")], axum::Json(json!({"network":true}))) + } + })); + let mut page = SameDocumentPage::with_routes(routes).await; + let report = page.evaluate(r#"(async () => { + const registration = await navigator.serviceWorker.register('/request-worker.js', {scope:'/native-request/'}); + const tick = () => new Promise(resolve => setTimeout(resolve,10)); + for (let n=0; n<500 && registration.active?.state !== 'activated'; ++n) await tick(); + if (registration.active?.state !== 'activated') throw new Error('worker not activated'); + const frame = document.createElement('iframe'); + const loaded = new Promise(resolve => frame.onload = resolve); + frame.src = '/native-request/frame.html'; + document.body.appendChild(frame); + await loaded; + if (!frame.contentWindow.navigator.serviceWorker.controller) throw new Error('missing controller'); + const worker = await new Promise((resolve,reject) => { + const xhr = new frame.contentWindow.XMLHttpRequest(); + xhr.open('GET','/native-request/echo',true,'alice','secret'); + xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } }; + xhr.onerror = () => reject(new Error('controlled XHR failed')); + xhr.send(); + }); + const cache = await caches.open('native-request-url'); + const keys = await cache.keys(); + if (keys.length !== 1) throw new Error('missing native cache key'); + const key = keys[0], url = key.url; + Object.defineProperty(key, 'url', {get() { throw new Error('public key url getter'); }}); + Object.defineProperty(key, 'toString', {value() { throw new Error('public key toString'); }}); + const constructors = []; + let copy; + for (const init of [undefined, {}, {cache:'no-store'}]) { + try { const value = new Request(key,init); constructors.push({url:value.url}); copy ||= value; } + catch(e) { constructors.push({error:e.name}); } + } + const forwarded = []; + for (const input of [key,copy]) { + if (!input) { forwarded.push({error:'no copy'}); continue; } + try { + const response = await fetch(input); + forwarded.push({status:response.status,data:await response.json()}); + } catch(e) { forwarded.push({error:e.name}); } + } + const refused = []; + for (const input of [url,{url}]) { + try { new Request(input); refused.push('allowed'); } catch(e) { refused.push(e.name); } + try { await fetch(input); refused.push('allowed'); } catch(e) { refused.push(e.name); } + } + frame.remove(); + await registration.unregister(); + await caches.delete('native-request-url'); + return {worker,window:{url,constructors,forwarded,refused}}; + })()"#).await; + let expected_url = page.base_url.replace("http://", "http://alice:secret@").replace("/history.html", "/native-request/echo"); + for realm in ["worker", "window"] { + assert_eq!(report[realm]["url"], expected_url, "{realm}: {report}"); + assert_eq!(report[realm]["constructors"], json!([ + {"url":expected_url},{"url":expected_url},{"url":expected_url} + ]), "{realm}: {report}"); + assert_eq!(report[realm]["forwarded"], json!([ + {"status":200,"data":{"network":true}}, {"status":200,"data":{"network":true}} + ]), "{realm}: {report}"); + } + assert_eq!(report["worker"]["refused"], json!(["TypeError","TypeError"])); + assert_eq!(report["window"]["refused"], json!(["TypeError","TypeError","TypeError","TypeError"])); + assert_eq!(requests.load(Ordering::SeqCst),4, "rejected external inputs must not reach HTTP: {report}"); + }).await; +} diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index 36cdda359f..4e7d5a773d 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -160,7 +160,7 @@ pub(crate) use self::preload::{ }; pub(crate) use self::request::build_service_worker_request_object; pub(crate) use self::request::{ - FetchArgs, RequestInfo, parse_fetch_init, parse_request_redirect_mode_label, + FetchArgs, RequestInfo, RequestInputUrl, parse_fetch_init, parse_request_redirect_mode_label, request_object_credentials_mode, validate_fetch_body, }; pub(crate) use self::request::{FetchArgumentError, convert_fetch_arguments}; @@ -172,7 +172,6 @@ pub(crate) use self::request::{ mark_request_input_body_used_for_fetch, request_body_already_used_error, request_headers_guard_for_mode, try_resolve_request_constructor_url_for_base, try_resolve_request_constructor_url_for_child, validate_request_method_body, - validate_request_url_credentials, }; pub(in crate::network_host) use self::request_scope::{ XHR_CHILD_CONTEXT_HANDLE_SLOT, active_subresource_network_partition_key, diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/request.rs b/moli-renderer-v8/src/network_host/fetch/bindings/request.rs index ec9226e8c1..4fa0e4619f 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/request.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/request.rs @@ -140,8 +140,8 @@ pub(super) fn prepare_window_fetch_request<'s>( let cors_preflight_request_headers = request_headers.clone(); let request_headers = merge_byte_string_request_headers(host.extra_http_headers(), &request_headers); - let resolved_url = resolve_context_url(&base_url, &parsed.url, None)?; - validate_request_url_credentials(&resolved_url)?; + let resolved_url = resolve_context_url(&base_url, parsed.url.as_str(), None)?; + parsed.url.validate_credentials(&resolved_url)?; let referrer = parsed .init_validation .validate(scope, parsed.request_mode.as_ref(), &parsed.cache)? diff --git a/moli-renderer-v8/src/network_host/fetch/input.rs b/moli-renderer-v8/src/network_host/fetch/input.rs index 898b69c093..d32a3ba6cd 100644 --- a/moli-renderer-v8/src/network_host/fetch/input.rs +++ b/moli-renderer-v8/src/network_host/fetch/input.rs @@ -3,7 +3,7 @@ use crate::webidl; use std::str::FromStr; pub(super) struct ParsedWindowFetchInput { - pub(super) url: String, + pub(super) url: RequestInputUrl, pub(super) blob_url_entry: Option, pub(super) method: String, pub(super) body: Option, diff --git a/moli-renderer-v8/src/network_host/request.rs b/moli-renderer-v8/src/network_host/request.rs index 1e16a90e24..a2ded80ffd 100644 --- a/moli-renderer-v8/src/network_host/request.rs +++ b/moli-renderer-v8/src/network_host/request.rs @@ -20,9 +20,9 @@ pub(crate) use self::init::{ }; pub(crate) use self::init::{parse_request_redirect_mode_label, request_redirect_mode_label}; pub(crate) use self::input::{ - RequestInfo, mark_request_input_body_used_for_fetch, request_body_already_used_error, - request_headers_guard_for_mode, try_resolve_request_constructor_url_for_base, - try_resolve_request_constructor_url_for_child, validate_request_method_body, - validate_request_url_credentials, + RequestInfo, RequestInputUrl, mark_request_input_body_used_for_fetch, + request_body_already_used_error, request_headers_guard_for_mode, + try_resolve_request_constructor_url_for_base, try_resolve_request_constructor_url_for_child, + validate_request_method_body, }; pub(in crate::network_host) use self::input::{RequestMethodError, normalize_request_method}; diff --git a/moli-renderer-v8/src/network_host/request/bindings/init.rs b/moli-renderer-v8/src/network_host/request/bindings/init.rs index 4cf8d79cb3..acd21f23dc 100644 --- a/moli-renderer-v8/src/network_host/request/bindings/init.rs +++ b/moli-renderer-v8/src/network_host/request/bindings/init.rs @@ -2,7 +2,6 @@ use super::super::init::RequestInitMembers; use super::super::input::{ normalize_fetch_request_method, request_body_already_used_error, request_headers_guard_for_mode, request_method_allows_body, - try_resolve_request_constructor_url_for_scope, validate_request_url_credentials, }; use super::super::*; use crate::webidl; @@ -40,15 +39,8 @@ pub(super) fn request_initial_state<'s>( child_handle: Option, base_url: Option, ) -> Result { - let (url_str, inherited, input_object) = input.materialize(scope)?; - let url_resolved = - try_resolve_request_constructor_url_for_scope(scope, &url_str, child_handle, base_url) - .map_err(|_| { - webidl::WebIdlError::custom_message("Failed to construct 'Request': invalid URL") - })?; - if let Ok(url) = url::Url::parse(&url_resolved) { - validate_request_url_credentials(&url)?; - } + let (url, inherited, input_object) = input.materialize(scope)?; + let url_resolved = url.into_constructor_url(scope, child_handle, base_url)?; let method = inherited .as_ref() .map(|snapshot| snapshot.method.clone()) diff --git a/moli-renderer-v8/src/network_host/request/input.rs b/moli-renderer-v8/src/network_host/request/input.rs index d3ed57630c..76b4317f27 100644 --- a/moli-renderer-v8/src/network_host/request/input.rs +++ b/moli-renderer-v8/src/network_host/request/input.rs @@ -113,27 +113,83 @@ impl<'s> webidl::WebIdlConverter<'s> for RequestInfo<'s> { } } +/// Keep native URL authority through constructor and fetch preparation. +/// The request-like compatibility extension still uses ordinary URL validation. +pub(crate) enum RequestInputUrl { + Native(String), + Converted(String), +} + +impl RequestInputUrl { + pub(crate) fn as_str(&self) -> &str { + match self { + Self::Native(url) | Self::Converted(url) => url, + } + } + + pub(crate) fn validate_credentials(&self, parsed: &Url) -> Result<(), webidl::WebIdlError> { + match self { + Self::Native(_) => Ok(()), + Self::Converted(_) => validate_request_url_credentials(parsed), + } + } + + pub(crate) fn into_constructor_url( + self, + scope: &mut v8::PinScope<'_, '_>, + child_handle: Option, + base_url: Option, + ) -> Result { + match self { + Self::Native(url) => Ok(url), + Self::Converted(url) => { + let resolved = try_resolve_request_constructor_url_for_scope( + scope, + &url, + child_handle, + base_url, + ) + .map_err(|_| { + webidl::WebIdlError::custom_message( + "Failed to construct 'Request': invalid URL", + ) + })?; + if let Ok(parsed) = Url::parse(&resolved) { + validate_request_url_credentials(&parsed)?; + } + Ok(resolved) + } + } + } +} + impl<'s> RequestInfo<'s> { pub(crate) fn materialize( self, scope: &mut v8::PinScope<'s, '_>, ) -> Result< ( - String, + RequestInputUrl, Option, Option>, ), webidl::WebIdlError, > { match self { - Self::Url(url) => Ok((url, None, None)), - Self::Legacy(source, snapshot) => { - Ok((snapshot.url.clone(), Some(*snapshot), Some(source))) - } + Self::Url(url) => Ok((RequestInputUrl::Converted(url), None, None)), + Self::Legacy(source, snapshot) => Ok(( + RequestInputUrl::Converted(snapshot.url.clone()), + Some(*snapshot), + Some(source), + )), Self::Native(source) => { let snapshot = request_input_snapshot_for_constructor(scope, source.into())? .expect("native Request snapshot"); - Ok((snapshot.url.clone(), Some(snapshot), Some(source))) + Ok(( + RequestInputUrl::Native(snapshot.url.clone()), + Some(snapshot), + Some(source), + )) } } } @@ -424,7 +480,7 @@ fn object_bool_property( .map(|value| value.boolean_value(scope)) } -pub(crate) fn validate_request_url_credentials(url: &url::Url) -> Result<(), webidl::WebIdlError> { +fn validate_request_url_credentials(url: &url::Url) -> Result<(), webidl::WebIdlError> { if !url.username().is_empty() || url.password().is_some_and(|value| !value.is_empty()) { return Err(webidl::WebIdlError::custom_message( "Request URL must not include credentials", diff --git a/moli-renderer-v8/src/worker/global_scope/fetch.rs b/moli-renderer-v8/src/worker/global_scope/fetch.rs index c44e387f4d..bec3f40d13 100644 --- a/moli-renderer-v8/src/worker/global_scope/fetch.rs +++ b/moli-renderer-v8/src/worker/global_scope/fetch.rs @@ -2405,14 +2405,14 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>( init.validation, ) }; - let resolved_url = resolve_context_url(base_url, &url_input, None)?; + let resolved_url = resolve_context_url(base_url, url_input.as_str(), None)?; crate::network_host::validate_fetch_body( body.is_some() || body_stream.is_some(), has_stream_body, &method, request_mode, )?; - crate::network_host::validate_request_url_credentials(&resolved_url)?; + url_input.validate_credentials(&resolved_url)?; if let Some(referrer) = init_validation.validate(scope, request_mode.as_ref(), &metadata.cache)? { diff --git a/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs b/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs index 0ca140d75b..0f377b5e50 100644 --- a/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs +++ b/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs @@ -199,6 +199,7 @@ mod client_lifecycle_and_sync; mod fetch_abort_reasons; mod fetch_events_and_messages; mod fetch_request_headers; +mod fetch_request_urls; mod keepalive_fetch; mod messages_clients_and_windows; mod worker_and_fetch_lifecycle; diff --git a/moli-renderer-v8/src/worker/thread/tests/lifecycle/fetch_request_urls.rs b/moli-renderer-v8/src/worker/thread/tests/lifecycle/fetch_request_urls.rs new file mode 100644 index 0000000000..9a346cd784 --- /dev/null +++ b/moli-renderer-v8/src/worker/thread/tests/lifecycle/fetch_request_urls.rs @@ -0,0 +1,64 @@ +use super::*; + +#[tokio::test] +async fn native_request_url_copy_preserves_credentials_and_upload_body() { + ensure_v8(); + let mut handle = spawn_service_worker_for_test( + r#" + let attempt = 0; + addEventListener('fetch', event => event.respondWith((async () => { + const original = event.request; + const url = original.url; + const variant = attempt++; + Object.defineProperty(original, 'url', {get() { throw new Error('public url getter'); }}); + Object.defineProperty(original, 'toString', {value() { throw new Error('public toString'); }}); + try { + const copy = variant === 0 ? new Request(original) : + new Request(original, variant === 1 ? {} : {cache:'no-store'}); + const body = await copy.text(); + copy.headers.set('accept', 'changed'); + const rejected = input => { + try { new Request(input); return 'allowed'; } catch (e) { return e.name; } + }; + return new Response(JSON.stringify({ + url:copy.url, body, used:copy.bodyUsed, + mode:copy.mode, credentials:copy.credentials, cache:copy.cache, + referrer:copy.referrer, signal:copy.signal instanceof AbortSignal, + headers:[original.headers.get('accept'), copy.headers.get('accept')], + rejected:[rejected(url), rejected({url})] + })); + } catch (e) { + return new Response(JSON.stringify({error:e.name, message:e.message})); + } + })())); + "#, + ); + let url = "https://alice:secret@example.test/app/data.txt"; + for (id, changed) in [(940, false), (941, false), (942, true)] { + let mut request = service_worker_fetch_request_for_test(); + request.url = url::Url::parse(url).unwrap(); + request.method = "POST".into(); + request.body = Some(bytes::Bytes::from_static(b"native request body")); + request.credentials_mode = moli_fetch::RequestCredentialsMode::Include; + request.metadata.referrer = "https://example.test/from".into(); + let completion = + dispatch_service_worker_fetch_event_with_request_for_test(&mut handle, id, request) + .await; + let ServiceWorkerFetchResult::Response(response) = completion.result else { + panic!("expected native Request copy observations"); + }; + let observed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!( + observed, + serde_json::json!({ + "url":url, "body":"native request body", "used":true, + "mode":"cors", "credentials":"include", + "cache":if changed {"no-store"} else {"default"}, + "referrer":if changed {"about:client"} else {"https://example.test/from"}, + "signal":true, "headers":["text/plain","changed"], + "rejected":["TypeError","TypeError"] + }) + ); + } + handle.terminate_and_join(); +}