mirror of
https://github.com/lexmount/moli.git
synced 2026-11-14 00:08:12 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user