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.
This commit is contained in:
ldm0
2026-11-08 22:30:18 +08:00
parent ef95ce9c46
commit b0e803695e
11 changed files with 265 additions and 28 deletions
@@ -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;
@@ -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")], "<!doctype html><body>controlled</body>")
}))
.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;
}
+1 -2
View File
@@ -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,
@@ -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)?
@@ -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<CapturedBlobUrl>,
pub(super) method: String,
pub(super) body: Option<bytes::Bytes>,
+4 -4
View File
@@ -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};
@@ -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<crate::document_runtime::DomHandle>,
base_url: Option<url::Url>,
) -> Result<RequestConstructionState, webidl::WebIdlError> {
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())
@@ -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<crate::document_runtime::DomHandle>,
base_url: Option<Url>,
) -> Result<String, webidl::WebIdlError> {
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<RequestInputSnapshot>,
Option<v8::Local<'s, v8::Object>>,
),
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",
@@ -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)?
{
@@ -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;
@@ -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();
}