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();
+}