From 3025bcdaffddeab4953cb84a2ec8ad6757bf5ead Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 10 Sep 2026 13:27:56 +0800 Subject: [PATCH] fix: validate RequestInit consistently before fetch abort --- .../wpt-cross-current/passed-cases.txt | 2 + .../runtime/request_init_validation.js | 175 ++++++++++++++++++ moli-core/tests/web_apis.rs | 2 + moli-core/tests/web_apis/request_init.rs | 57 ++++++ moli-renderer-v8/src/network_host.rs | 2 +- .../src/network_host/fetch/bindings.rs | 38 ++-- .../network_host/fetch/bindings/request.rs | 16 +- .../src/network_host/fetch/input.rs | 15 +- moli-renderer-v8/src/network_host/request.rs | 6 +- .../src/network_host/request/bindings/init.rs | 53 +++--- .../src/network_host/request/init.rs | 145 ++++++++++++--- .../src/network_host/request/input.rs | 57 ++++-- .../src/worker/global_scope/fetch.rs | 23 ++- 13 files changed, 500 insertions(+), 91 deletions(-) create mode 100644 moli-core/tests/fixtures/runtime/request_init_validation.js create mode 100644 moli-core/tests/web_apis/request_init.rs diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 3976a2be71..15cdeb0230 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -5534,6 +5534,8 @@ fetch/api/crashtests/request.html fetch/api/policies/csp-blocked-worker.html fetch/api/policies/referrer-no-referrer-worker.html fetch/api/request/multi-globals/url-parsing.html +fetch/api/request/request-error.any.js?moli-wpt-any=dedicatedworker +fetch/api/request/request-error.any.js?moli-wpt-any=window fetch/api/request/url-encoding.html fetch/api/response/many-empty-chunks-crash.html fetch/api/response/multi-globals/url-parsing.html diff --git a/moli-core/tests/fixtures/runtime/request_init_validation.js b/moli-core/tests/fixtures/runtime/request_init_validation.js new file mode 100644 index 0000000000..a06812e026 --- /dev/null +++ b/moli-core/tests/fixtures/runtime/request_init_validation.js @@ -0,0 +1,175 @@ +async function runRequestInitValidationProbe(scenario, url) { + const errors = []; + const check = (value, label) => { if (!value) errors.push(label); }; + const controller = new AbortController(); + const reason = {probe: 'already aborted'}; + controller.abort(reason); + const fetchError = async (input, init, expected, label, fetchFunction = fetch, receiver = self) => { + let promise; + try { promise = fetchFunction.call(receiver, input, init); } + catch (error) { errors.push(label + ': synchronous throw ' + error); return; } + try { await promise; errors.push(label + ': fulfilled'); } + catch (error) { + check(expected === reason ? error === reason : error instanceof expected, + label + ': wrong rejection ' + error); + } + }; + const throwsTypeError = (input, init, label) => { + try { new Request(input, init); errors.push(label + ': accepted'); } + catch (error) { check(error instanceof TypeError, label + ': wrong exception ' + error); } + }; + + if (scenario === 'invalid') { + const invalid = [ + ['navigate', {mode: 'navigate'}], + ['default mode with only-if-cached', {cache: 'only-if-cached'}], + ['cors with only-if-cached', {mode: 'cors', cache: 'only-if-cached'}], + ['no-cors with only-if-cached', {mode: 'no-cors', cache: 'only-if-cached'}], + ['invalid referrer', {referrer: 'http://:invalid'}], + ...['', 'IN VALID', ' GET', 'GET\t', 'a/b', 'a:b', 'é', '\u0100', 'TRACE'] + .map(method => ['method ' + JSON.stringify(method), {method}]), + ...[['cache', 'reload'], ['referrerPolicy', 'origin'], ['duplex', 'half'], + ['mode', 'cors'], ['credentials', 'omit'], ['redirect', 'follow']] + .flatMap(([key, valid]) => ['BAD', null, valid.toUpperCase(), ' ' + valid] + .map(value => [key + ' ' + JSON.stringify(value), {[key]: value}])) + ]; + for (const [label, init] of invalid) { + throwsTypeError(url, init, label); + await fetchError(url, init, TypeError, label + ' live'); + await fetchError(url, {...init, signal: controller.signal}, TypeError, label + ' aborted'); + } + for (const credentials of ['user:pass', 'user', ':pass']) { + const credentialURL = new URL(url); + const [username, password = ''] = credentials.split(':'); + credentialURL.username = username; + credentialURL.password = password; + throwsTypeError(credentialURL.href, {}, 'URL credentials ' + credentials); + await fetchError(credentialURL.href, {signal: controller.signal}, TypeError, + 'URL credentials ' + credentials + ' aborted'); + } + } else if (scenario === 'valid') { + const valid = [ + [{window: undefined}, {}], [{window: null}, {}], + [{method: null}, {method: 'null'}], [{method: 'pOsT'}, {method: 'POST'}], + [{method: "a!#$%&'*+-.^_`|~"}, {method: "a!#$%&'*+-.^_`|~"}], + [{referrer: ''}, {referrer: ''}], + [{referrer: 'about:client'}, {referrer: 'about:client'}], + [{referrer: new URL('/referrer', url).href}, {referrer: new URL('/referrer', url).href}], + [{referrer: 'https://other.invalid/'}, {referrer: 'about:client'}], + [{duplex: 'half'}, {duplex: 'half'}], + ...['default', 'no-store', 'reload', 'no-cache', 'force-cache', 'only-if-cached'] + .map(cache => [{cache, mode: 'same-origin'}, {cache, mode: 'same-origin'}]), + ...['', 'no-referrer', 'no-referrer-when-downgrade', 'same-origin', 'origin', + 'strict-origin', 'origin-when-cross-origin', 'strict-origin-when-cross-origin', 'unsafe-url'] + .map(referrerPolicy => [{referrerPolicy}, {referrerPolicy}]) + ]; + for (const [init, expected] of valid) { + const request = new Request(url, init); + for (const [key, value] of Object.entries(expected)) { + check(request[key] === value, JSON.stringify(init) + ': ' + key + '=' + request[key]); + } + await fetchError(url, {...init, signal: controller.signal}, reason, JSON.stringify(init)); + } + } else if (scenario === 'inheritance') { + const makeSource = () => new Request(url, { + mode: 'same-origin', cache: 'only-if-cached', method: 'POST', body: 'original body' + }); + for (const init of [{mode: 'cors'}, {mode: 'no-cors'}, {mode: 'navigate'}, + {referrer: 'http://:invalid'}, {cache: 'BAD'}, {referrerPolicy: null}]) { + const source = makeSource(); + throwsTypeError(source, init, 'inherited Request ' + JSON.stringify(init)); + check(!source.bodyUsed, 'failed constructor consumed input body'); + if (!source.bodyUsed) check(await source.text() === 'original body', 'constructor input body remains readable'); + for (const aborted of [false, true]) { + const source = makeSource(); + const promise = fetchError(source, {...init, ...(aborted ? {signal: controller.signal} : {})}, + TypeError, 'inherited fetch ' + JSON.stringify(init) + ' aborted=' + aborted); + check(!source.bodyUsed, 'failed fetch synchronously consumed input body'); + await promise; + check(!source.bodyUsed, 'failed fetch consumed input body'); + if (!source.bodyUsed) check(await source.text() === 'original body', 'fetch input body remains readable'); + } + } + const cached = new Request(url, {mode: 'same-origin', cache: 'only-if-cached'}); + for (const init of [{}, {mode: undefined, cache: undefined}, {cache: 'reload', mode: 'cors'}]) { + const copy = new Request(cached, init); + check(copy.mode === (init.mode || 'same-origin'), 'inherited effective mode'); + check(copy.cache === (init.cache || 'only-if-cached'), 'inherited effective cache'); + await fetchError(cached, {...init, signal: controller.signal}, reason, 'valid inheritance'); + } + } else if (scenario === 'realm') { + if (document.readyState === 'loading') { + await new Promise(resolve => addEventListener('load', resolve, {once: true})); + } + const frame = document.createElement('iframe'); + const loaded = new Promise(resolve => frame.onload = resolve); + frame.src = 'about:blank'; + document.body.append(frame); + await loaded; + const child = frame.contentWindow; + for (const [fn, receiver, ErrorConstructor, PromiseConstructor] of [ + [fetch, child, TypeError, Promise], + [child.fetch, self, child.TypeError, child.Promise] + ]) { + for (const init of [{cache: 'BAD'}, {referrerPolicy: null}, {duplex: 'full'}, {redirect: null}]) { + await fetchError(url, {...init, signal: controller.signal}, ErrorConstructor, + 'conversion realm ' + JSON.stringify(init), fn, receiver); + } + for (const init of [{mode: 'navigate'}, {cache: 'only-if-cached'}, + {referrer: 'http://:invalid'}]) { + await fetchError(url, {...init, signal: controller.signal}, ErrorConstructor, + 'construction realm ' + JSON.stringify(init), fn, receiver); + const promise = fn.call(receiver, url, init); + promise.catch(() => {}); + check(promise instanceof PromiseConstructor, 'construction rejection promise realm'); + } + } + frame.remove(); + } else if (scenario === 'getters') { + for (const init of [{mode: 'navigate'}, {mode: 'cors', cache: 'only-if-cached'}, + {window: 1}, {referrer: 'http://:invalid'}]) { + for (const member of ['body', 'signal']) { + const marker = {member}; + const options = {method: 'POST', ...init, get [member]() { throw marker; }}; + try { new Request(url, options); errors.push(member + ' getter accepted'); } + catch (error) { check(error === marker, member + ' getter exception replaced'); } + } + } + const source = new Request(url, {signal: controller.signal}); + const copy = new Request(source, {signal: undefined}); + check(copy.signal.aborted && copy.signal.reason === reason, 'undefined signal preserves inheritance'); + } else if (scenario === 'window') { + // Fetch's RequestInit.window is still specified and covered by WPT, even + // though Chromium 145 ignores the member. + const invalid = [ + ['window string', {window: 'null'}], + ['window false', {window: false}], + ['window zero', {window: 0}], + ['window object', {window: {toString() { throw new Error('must not coerce window'); }}}], + ]; + for (const [label, init] of invalid) { + throwsTypeError(url, init, label); + await fetchError(url, init, TypeError, label + ' live'); + await fetchError(url, {...init, signal: controller.signal}, TypeError, label + ' aborted'); + const source = new Request(url, {method: 'POST', body: 'original body'}); + throwsTypeError(source, init, label + ' inherited'); + check(!source.bodyUsed, 'window rejection consumed constructor input'); + if (!source.bodyUsed) { + await fetchError(source, {...init, signal: controller.signal}, TypeError, label + ' inherited aborted'); + check(!source.bodyUsed, 'window rejection consumed fetch input'); + if (!source.bodyUsed) check(await source.text() === 'original body', 'window input remains readable'); + } + } + let reads = 0; + const init = {get window() { ++reads; return null; }}; + new Request(url, init); + check(reads === 1, 'Request reads window once'); + reads = 0; + await fetchError(url, {get window() { ++reads; return null; }, signal: controller.signal}, + reason, 'fetch reads window'); + check(reads === 1, 'fetch reads window once'); + } else { + throw new Error('unknown scenario ' + scenario); + } + return {errors}; +} diff --git a/moli-core/tests/web_apis.rs b/moli-core/tests/web_apis.rs index 8660370c76..83efd0191c 100644 --- a/moli-core/tests/web_apis.rs +++ b/moli-core/tests/web_apis.rs @@ -22,6 +22,8 @@ mod abort_signal; mod callback_cleanup; #[path = "web_apis/event_dispatch.rs"] mod event_dispatch; +#[path = "web_apis/request_init.rs"] +mod request_init; fn diagnostic_global<'a>( page: &'a moli_core::page::Page, diff --git a/moli-core/tests/web_apis/request_init.rs b/moli-core/tests/web_apis/request_init.rs new file mode 100644 index 0000000000..ad54884ede --- /dev/null +++ b/moli-core/tests/web_apis/request_init.rs @@ -0,0 +1,57 @@ +use super::*; + +async fn assert_request_init_validation(scenario: &str, targets: &[&str]) -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let source = format!( + "{}\nrunRequestInitValidationProbe({}, {}).then(finish, error => finish({{error: String(error)}}));", + include_str!("../fixtures/runtime/request_init_validation.js"), + serde_json::to_string(scenario)?, + serde_json::to_string(&server.url("/compat/child-dynamic-markup-document"))?, + ); + for target in targets { + let observed = tokio::time::timeout( + Duration::from_secs(20), + super::event_dispatch::run_probe(&browser, &server, target, &source), + ) + .await??; + assert_eq!( + observed, + serde_json::json!({"errors": []}), + "{scenario}/{target}" + ); + } + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn request_and_fetch_reject_invalid_init_before_abort() -> Result<()> { + assert_request_init_validation("invalid", &["window", "child", "worker"]).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn request_and_fetch_accept_valid_init_and_preserve_abort_reason() -> Result<()> { + assert_request_init_validation("valid", &["window", "child", "worker"]).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn request_and_fetch_validate_inherited_modes_without_consuming_invalid_input() -> Result<()> +{ + assert_request_init_validation("inheritance", &["window", "child", "worker"]).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn fetch_init_errors_and_rejected_promises_use_the_function_realm() -> Result<()> { + assert_request_init_validation("realm", &["window", "child"]).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn request_init_window_accepts_only_null_without_consuming_invalid_input() -> Result<()> { + assert_request_init_validation("window", &["window", "child", "worker"]).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn request_init_getter_exceptions_precede_construction_validation() -> Result<()> { + assert_request_init_validation("getters", &["window", "child", "worker"]).await +} diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index f8387e22c8..2b44265815 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -131,7 +131,7 @@ pub(crate) use self::request::{FetchArgumentError, RequestUrlError, convert_fetc pub(crate) use self::request::{ mark_request_input_body_used_for_fetch, request_input_snapshot, try_resolve_request_constructor_url, try_resolve_request_constructor_url_for_base, - try_resolve_request_constructor_url_for_child, + try_resolve_request_constructor_url_for_child, validate_request_url_credentials, }; pub(crate) use self::request::{ parse_fetch_init, parse_request_redirect_mode_label, request_object_credentials_mode, diff --git a/moli-renderer-v8/src/network_host/fetch/bindings.rs b/moli-renderer-v8/src/network_host/fetch/bindings.rs index 09ef518492..84cd650d33 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings.rs @@ -7,9 +7,9 @@ use self::paths::{ reject_csp_fetch, reject_offline_fetch, reject_url_policy_fetch, resolve_local_fetch, spawn_network_fetch, }; -use self::request::prepare_window_fetch_request; +use self::request::{PreparedWindowFetchRequest, prepare_window_fetch_request}; use self::service_worker::dispatch_service_worker_fetch; -use super::input::{ParsedWindowFetchInput, parse_window_fetch_input}; +use super::input::parse_window_fetch_input; use super::promise::{make_rejected_promise, make_rejected_promise_with_value}; use super::*; use crate::native_bridge::abort::abort_error_value; @@ -181,25 +181,38 @@ pub(crate) fn window_fetch_callback<'s>( ); return; }; - if let Some(request_body_owner) = parsed.request_body_owner.take() { - let request_body_owner = v8::Local::new(scope, request_body_owner); - mark_request_input_body_used_for_fetch(scope, request_body_owner); - } let fetch_context = crate::native_bridge::WindowFetchContext::from_realm(binding); let relevant_context = { let context = fetch_context.script_realm().context(scope); v8::Global::new(scope, context) }; let relevant_context = v8::Local::new(scope, &relevant_context); + let request_body_owner = parsed.request_body_owner.take(); + let prepared = { + let scope = &mut v8::ContextScope::new(scope, relevant_context); + prepare_window_fetch_request(scope, parsed, fetch_context, unsafe { &*host_ptr }) + }; + let prepared = match prepared { + Ok(prepared) => prepared, + Err(error) => { + // Request construction uses the receiver's settings, but Blink's + // binding rejects construction exceptions in the function realm. + rv.set(make_rejected_promise(scope, &error.to_string()).into()); + return; + } + }; let scope = &mut v8::ContextScope::new(scope, relevant_context); - window_fetch_callback_in_relevant_realm(scope, parsed, signal, fetch_context, rv); + if let Some(request_body_owner) = request_body_owner { + let request_body_owner = v8::Local::new(scope, request_body_owner); + mark_request_input_body_used_for_fetch(scope, request_body_owner); + } + window_fetch_callback_in_relevant_realm(scope, prepared, signal, rv); } fn window_fetch_callback_in_relevant_realm<'s>( scope: &mut v8::PinScope<'s, '_>, - parsed: ParsedWindowFetchInput, + prepared: PreparedWindowFetchRequest, signal: Option>, - fetch_context: crate::native_bridge::WindowFetchContext, mut rv: v8::ReturnValue<'s, v8::Value>, ) { let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { @@ -209,13 +222,6 @@ fn window_fetch_callback_in_relevant_realm<'s>( let host = unsafe { &mut *host_ptr }; let signal = signal.as_ref().map(|signal| v8::Local::new(scope, signal)); - let prepared = match prepare_window_fetch_request(scope, parsed, fetch_context, host) { - Ok(prepared) => prepared, - Err(error) => { - rv.set(make_rejected_promise(scope, &error.to_string()).into()); - return; - } - }; host.break_on_dom_debugger_xhr_or_fetch_network_request(prepared.resolved_url.as_str()); if let Some(signal) = signal && host.abort_signal_aborted(scope, signal) 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 b6630a518d..e4fdac6904 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/request.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/request.rs @@ -11,6 +11,13 @@ pub(super) enum FetchPrepareError { PolicyContextUnavailable, ReportContextUnavailable, Url(ResolveContextUrlError), + WebIdl(crate::webidl::WebIdlError), +} + +impl From for FetchPrepareError { + fn from(error: crate::webidl::WebIdlError) -> Self { + Self::WebIdl(error) + } } impl From for FetchPrepareError { @@ -38,6 +45,7 @@ impl fmt::Display for FetchPrepareError { f.write_str("fetch: document report context is unavailable") } Self::Url(error) => error.fmt(f), + Self::WebIdl(error) => error.fmt(f), } } } @@ -46,6 +54,7 @@ impl std::error::Error for FetchPrepareError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Url(error) => Some(error), + Self::WebIdl(error) => Some(error), _ => None, } } @@ -125,6 +134,11 @@ pub(super) fn prepare_window_fetch_request<'s>( 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 referrer = parsed + .init_validation + .validate(scope, parsed.request_mode.as_ref(), &parsed.cache)? + .unwrap_or(parsed.referrer); Ok(PreparedWindowFetchRequest { frame_id, @@ -147,7 +161,7 @@ pub(super) fn prepare_window_fetch_request<'s>( redirect_mode: parsed.redirect_mode, priority: parsed.priority, cache: parsed.cache, - referrer: parsed.referrer, + referrer, referrer_policy: parsed.referrer_policy, integrity: parsed.integrity, keepalive: parsed.keepalive, diff --git a/moli-renderer-v8/src/network_host/fetch/input.rs b/moli-renderer-v8/src/network_host/fetch/input.rs index 3137d83738..ed61341f9e 100644 --- a/moli-renderer-v8/src/network_host/fetch/input.rs +++ b/moli-renderer-v8/src/network_host/fetch/input.rs @@ -17,6 +17,7 @@ pub(super) struct ParsedWindowFetchInput { pub(super) integrity: String, pub(super) keepalive: bool, pub(super) request_body_owner: Option>, + pub(super) init_validation: super::super::request::RequestInitValidation, } pub(super) fn parse_window_fetch_input<'s>( @@ -56,7 +57,8 @@ pub(super) fn parse_window_fetch_input<'s>( } let inherited_credentials = request_object_credentials_mode(scope, req_obj)?; let request_mode = init - .request_mode + .validation + .mode .or_else(|| moli_fetch::RequestMode::from_str(&inherited.mode).ok()) .unwrap_or(moli_fetch::RequestMode::Cors); validate_no_cors_method(request_mode, &method)?; @@ -85,16 +87,20 @@ pub(super) fn parse_window_fetch_input<'s>( redirect_mode, priority, cache: init.cache.unwrap_or(inherited.cache), - referrer: init.referrer.unwrap_or(inherited.referrer), + referrer: inherited.referrer, referrer_policy: init.referrer_policy.unwrap_or(inherited.referrer_policy), integrity: init.integrity.unwrap_or(inherited.integrity), keepalive: init.keepalive.unwrap_or(inherited.keepalive), request_body_owner, + init_validation: init.validation, }) } else { let url = fetch_request_info_url(scope, arg0)?; let init = parse_fetch_init(scope, args, 1)?; - let request_mode = init.request_mode.unwrap_or(moli_fetch::RequestMode::Cors); + let request_mode = init + .validation + .mode + .unwrap_or(moli_fetch::RequestMode::Cors); validate_no_cors_method(request_mode, &init.method)?; let headers = if request_mode == moli_fetch::RequestMode::NoCors { filter_headers_for_guard(&init.headers, HeadersGuard::RequestNoCors) @@ -117,11 +123,12 @@ pub(super) fn parse_window_fetch_input<'s>( redirect_mode, priority: init.priority, cache: init.cache.unwrap_or_else(|| "default".to_owned()), - referrer: init.referrer.unwrap_or_else(|| "about:client".to_owned()), + referrer: "about:client".to_owned(), referrer_policy: init.referrer_policy.unwrap_or_default(), integrity: init.integrity.unwrap_or_default(), keepalive: init.keepalive.unwrap_or(false), request_body_owner: None, + init_validation: init.validation, }) } } diff --git a/moli-renderer-v8/src/network_host/request.rs b/moli-renderer-v8/src/network_host/request.rs index 5136618164..cef1682628 100644 --- a/moli-renderer-v8/src/network_host/request.rs +++ b/moli-renderer-v8/src/network_host/request.rs @@ -12,11 +12,13 @@ pub(crate) use self::bindings::request_constructor_callback; pub(crate) use self::error::{FetchArgumentError, RequestUrlError}; pub(crate) use self::init::convert_fetch_arguments; pub(in crate::network_host) use self::init::request_credentials_mode_label; -pub(crate) use self::init::{parse_fetch_init, request_object_credentials_mode}; +pub(crate) use self::init::{ + RequestInitValidation, parse_fetch_init, request_object_credentials_mode, +}; pub(crate) use self::init::{parse_request_redirect_mode_label, request_redirect_mode_label}; pub(in crate::network_host) use self::input::normalize_request_method; pub(crate) use self::input::{ mark_request_input_body_used_for_fetch, request_input_snapshot, try_resolve_request_constructor_url, try_resolve_request_constructor_url_for_base, - try_resolve_request_constructor_url_for_child, + try_resolve_request_constructor_url_for_child, validate_request_url_credentials, }; 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 ff7335dc74..21d9092187 100644 --- a/moli-renderer-v8/src/network_host/request/bindings/init.rs +++ b/moli-renderer-v8/src/network_host/request/bindings/init.rs @@ -1,14 +1,14 @@ use super::super::init::RequestInitMembers; use super::super::input::{ - normalize_request_method, normalize_request_referrer, request_body_already_used_error, + normalize_fetch_request_method, request_body_already_used_error, request_headers_guard_for_mode, request_input_snapshot_for_constructor, request_input_url, request_method_allows_body, request_signal_snapshot_from_value, - try_resolve_request_constructor_url_for_scope, + try_resolve_request_constructor_url_for_scope, validate_request_url_credentials, }; use super::super::*; use crate::web_api_interfaces; use crate::webidl; -use moli_fetch::{RequestMode, RequestRedirectMode}; +use moli_fetch::RequestRedirectMode; pub(super) struct RequestConstructionState { pub(super) url_resolved: String, @@ -51,6 +51,9 @@ pub(super) fn request_initial_state<'s>( .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 method = inherited .as_ref() .map(|snapshot| snapshot.method.clone()) @@ -134,10 +137,17 @@ pub(super) fn apply_request_init_overrides<'s>( state: &mut RequestConstructionState, ) -> Result<(), webidl::WebIdlError> { let parsed = webidl::parse_dictionary_object::(scope, init)?; + let validation = parsed.validation(); + if let Some(mode) = validation.mode { + state.mode = mode.as_ref().to_owned(); + } + if let Some(cache) = parsed.cache { + state.cache = cache.0.to_owned(); + } let method_overridden = parsed.method.is_some(); if let Some(method) = parsed.method { - state.method = normalize_request_method(&method)?; + state.method = normalize_fetch_request_method(&method)?; } let init_body_value = webidl::property_result( scope, @@ -168,20 +178,19 @@ pub(super) fn apply_request_init_overrides<'s>( if let Some(headers) = parsed.headers { state.headers = headers; } - let signal_key = v8str(scope, "signal"); - if init.has(scope, signal_key.into()).unwrap_or(false) { - let signal = init - .get(scope, signal_key.into()) - .unwrap_or_else(|| v8::undefined(scope).into()); + if let Some(signal) = webidl::property_result( + scope, + init, + "signal", + webidl::Context::member("RequestInit", "signal"), + )? + .filter(|value| !value.is_undefined()) + { state.signal = request_signal_snapshot_from_value(scope, signal)?; } - if let Some(mode) = parsed.mode.map(|value| value.0) { - if mode == RequestMode::Navigate { - return Err(webidl::WebIdlError::custom_message( - "Cannot construct a Request with a RequestInit whose mode member is \"navigate\".", - )); - } - state.mode = mode.as_ref().to_owned(); + if let Some(referrer) = validation.validate(scope, &state.mode, &state.cache)? + { + state.referrer = referrer; } if state.mode == "no-cors" && !moli_fetch::is_cors_safelisted_method(&state.method) { return Err(webidl::WebIdlError::custom_message( @@ -191,17 +200,11 @@ pub(super) fn apply_request_init_overrides<'s>( if let Some(credentials_mode) = parsed.credentials_mode.map(|value| value.0) { state.credentials = request_credentials_mode_label(credentials_mode).to_owned(); } - if let Some(cache) = parsed.cache { - state.cache = cache; - } if let Some(redirect) = parsed.redirect { state.redirect_mode = redirect.0; } - if let Some(referrer) = parsed.referrer { - state.referrer = normalize_request_referrer(scope, &referrer); - } if let Some(referrer_policy) = parsed.referrer_policy { - state.referrer_policy = referrer_policy; + state.referrer_policy = referrer_policy.0.to_owned(); } if let Some(integrity) = parsed.integrity { state.integrity = integrity; @@ -209,8 +212,8 @@ pub(super) fn apply_request_init_overrides<'s>( if let Some(priority) = parsed.priority { state.priority = priority.0; } - if let Some(duplex) = parsed.duplex { - state.duplex = duplex; + if parsed.duplex.is_some() { + state.duplex = "half".to_owned(); } if parsed.keepalive == Some(true) && init_body_is_readable_stream { return Err(webidl::WebIdlError::custom_message( diff --git a/moli-renderer-v8/src/network_host/request/init.rs b/moli-renderer-v8/src/network_host/request/init.rs index 3fec5fdafb..38168eb697 100644 --- a/moli-renderer-v8/src/network_host/request/init.rs +++ b/moli-renderer-v8/src/network_host/request/init.rs @@ -1,4 +1,4 @@ -use super::input::{normalize_request_method, normalize_request_referrer}; +use super::input::{normalize_fetch_request_method, normalize_request_referrer}; use super::*; use crate::webidl; use moli_fetch::{FetchPriorityHint, RequestCredentialsMode, RequestMode, RequestRedirectMode}; @@ -40,12 +40,11 @@ pub(crate) struct ParsedFetchInit { pub(crate) body_content_type: Option, pub(crate) headers: Vec<(String, String)>, pub(crate) headers_present: bool, - pub(crate) request_mode: Option, + pub(crate) validation: RequestInitValidation, pub(crate) credentials_mode: Option, pub(crate) redirect_mode: Option, pub(crate) priority: Option, pub(crate) cache: Option, - pub(crate) referrer: Option, pub(crate) referrer_policy: Option, pub(crate) integrity: Option, pub(crate) keepalive: Option, @@ -61,12 +60,11 @@ impl Default for ParsedFetchInit { body_content_type: None, headers: Vec::new(), headers_present: false, - request_mode: None, + validation: RequestInitValidation::default(), credentials_mode: None, redirect_mode: None, priority: None, cache: None, - referrer: None, referrer_policy: None, integrity: None, keepalive: None, @@ -74,6 +72,42 @@ impl Default for ParsedFetchInit { } } +#[derive(Debug, Clone, Default)] +pub(crate) struct RequestInitValidation { + pub(crate) mode: Option, + window_non_null: bool, + referrer: Option, +} + +impl RequestInitValidation { + // Run construction checks after dictionary conversion, using the effective + // inherited/overridden values and the request's relevant realm. Fetch must + // complete these checks before consuming an input body or observing abort. + pub(crate) fn validate( + &self, + scope: &mut v8::PinScope<'_, '_>, + mode: &str, + cache: &str, + ) -> Result, webidl::WebIdlError> { + if self.window_non_null { + return Err(webidl::WebIdlError::custom_message("RequestInit's window member must be null")); + } + let referrer = self + .referrer + .as_deref() + .map(|value| normalize_request_referrer(scope, value)) + .transpose() + .map_err(|_| webidl::WebIdlError::custom_message("Request referrer is not a valid URL"))?; + if self.mode == Some(RequestMode::Navigate) { + return Err(webidl::WebIdlError::custom_message("Cannot construct a Request with mode navigate")); + } + if cache == "only-if-cached" && mode != "same-origin" { + return Err(webidl::WebIdlError::custom_message("Request cache only-if-cached requires mode same-origin")); + } + Ok(referrer) + } +} + #[derive(Clone, Copy, webidl::WebIdlEnum)] #[webidl(name = "RequestCredentials", parse_with = parse_request_credentials_mode_webidl)] pub(super) struct RequestCredentialsModeWebIdl(pub(super) RequestCredentialsMode); @@ -90,25 +124,40 @@ pub(super) struct RequestRedirectModeWebIdl(pub(super) RequestRedirectMode); #[webidl(name = "RequestPriority", parse_with = parse_request_priority_webidl)] pub(super) struct RequestPriorityWebIdl(pub(super) FetchPriorityHint); +#[derive(Clone, Copy, webidl::WebIdlEnum)] +#[webidl(name = "RequestCache", parse_with = parse_request_cache_webidl)] +pub(super) struct RequestCacheWebIdl(pub(super) &'static str); + +#[derive(Clone, Copy, webidl::WebIdlEnum)] +#[webidl(name = "ReferrerPolicy", parse_with = parse_referrer_policy_webidl)] +pub(super) struct ReferrerPolicyWebIdl(pub(super) &'static str); + +#[derive(Clone, Copy, webidl::WebIdlEnum)] +#[webidl(name = "RequestDuplex")] +pub(super) enum RequestDuplexWebIdl { + #[webidl(token = "half")] + Half, +} + #[derive(webidl::WebIdlDictionary)] #[webidl(prefix = "RequestInit")] pub(super) struct RequestInitMembers { - #[webidl(legacy_nullish)] + #[webidl(converter = "byte_string")] pub(super) method: Option, - #[webidl(legacy_nullish)] - pub(super) cache: Option, + #[webidl(converter = "enum")] + pub(super) cache: Option, #[webidl(converter = "enum")] pub(super) mode: Option, - #[webidl(legacy_nullish, converter = "enum")] + #[webidl(converter = "enum")] pub(super) redirect: Option, - #[webidl(legacy_nullish)] + #[webidl(converter = "usv_string")] pub(super) referrer: Option, - #[webidl(legacy_nullish)] - pub(super) referrer_policy: Option, + #[webidl(converter = "enum")] + pub(super) referrer_policy: Option, #[webidl(legacy_nullish)] pub(super) integrity: Option, - #[webidl(legacy_nullish)] - pub(super) duplex: Option, + #[webidl(converter = "enum")] + pub(super) duplex: Option, #[webidl(with = request_init_headers_member)] pub(super) headers: Option>, #[webidl(name = "credentials", converter = "enum")] @@ -116,6 +165,32 @@ pub(super) struct RequestInitMembers { #[webidl(converter = "enum")] pub(super) priority: Option, pub(super) keepalive: Option, + #[webidl(name = "window", with = request_init_window_member)] + window_non_null: bool, +} + +impl RequestInitMembers { + pub(super) fn validation(&self) -> RequestInitValidation { + RequestInitValidation { + mode: self.mode.map(|value| value.0), + window_non_null: self.window_non_null, + referrer: self.referrer.clone(), + } + } +} + +fn request_init_window_member<'s>( + scope: &mut v8::PinScope<'s, '_>, + object: v8::Local<'s, v8::Object>, + _key: &str, +) -> Result { + webidl::property_result( + scope, + object, + "window", + webidl::Context::member("RequestInit", "window"), + ) + .map(|value| value.is_some_and(|value| !value.is_null_or_undefined())) } fn request_init_headers_member<'s>( @@ -148,6 +223,7 @@ pub(crate) fn parse_fetch_init<'s>( }; let init = webidl::parse_dictionary_object::(scope, init_object)?; + let validation = init.validation(); let priority = init.priority.map(|value| value.0); let method_present = init_object .has(scope, v8str(scope, "method").into()) @@ -156,7 +232,7 @@ pub(crate) fn parse_fetch_init<'s>( })?; let method = init .method - .map(|s| normalize_request_method(&s)) + .map(|s| normalize_fetch_request_method(&s)) .transpose()? .unwrap_or_else(|| "GET".to_owned()); @@ -197,13 +273,8 @@ pub(crate) fn parse_fetch_init<'s>( let mut extra_headers = init.headers.unwrap_or_default(); append_default_body_content_type(&mut extra_headers, body_content_type.as_deref()); - let request_mode = init.mode.map(|value| value.0); let credentials_mode = init.credentials_mode.map(|value| value.0); let redirect_mode = init.redirect.map(|value| value.0); - let referrer = init - .referrer - .map(|referrer| normalize_request_referrer(scope, &referrer)); - Ok(ParsedFetchInit { method, method_present, @@ -212,13 +283,12 @@ pub(crate) fn parse_fetch_init<'s>( body_content_type, headers: extra_headers, headers_present, - request_mode, + validation, credentials_mode, redirect_mode, priority, - cache: init.cache, - referrer, - referrer_policy: init.referrer_policy, + cache: init.cache.map(|value| value.0.to_owned()), + referrer_policy: init.referrer_policy.map(|value| value.0.to_owned()), integrity: init.integrity, keepalive: init.keepalive, }) @@ -272,6 +342,33 @@ fn parse_request_priority_webidl(value: &str) -> Option { .map(RequestPriorityWebIdl) } +fn parse_request_cache_webidl(value: &str) -> Option { + Some(RequestCacheWebIdl(match value { + "default" => "default", + "no-store" => "no-store", + "reload" => "reload", + "no-cache" => "no-cache", + "force-cache" => "force-cache", + "only-if-cached" => "only-if-cached", + _ => return None, + })) +} + +fn parse_referrer_policy_webidl(value: &str) -> Option { + Some(ReferrerPolicyWebIdl(match value { + "" => "", + "no-referrer" => "no-referrer", + "no-referrer-when-downgrade" => "no-referrer-when-downgrade", + "same-origin" => "same-origin", + "origin" => "origin", + "strict-origin" => "strict-origin", + "origin-when-cross-origin" => "origin-when-cross-origin", + "strict-origin-when-cross-origin" => "strict-origin-when-cross-origin", + "unsafe-url" => "unsafe-url", + _ => return None, + })) +} + #[cfg(test)] mod tests { use super::*; diff --git a/moli-renderer-v8/src/network_host/request/input.rs b/moli-renderer-v8/src/network_host/request/input.rs index 52b663038d..380934aa1c 100644 --- a/moli-renderer-v8/src/network_host/request/input.rs +++ b/moli-renderer-v8/src/network_host/request/input.rs @@ -3,6 +3,34 @@ use super::*; use crate::web_api_interfaces; use crate::webidl; +pub(super) fn normalize_fetch_request_method(method: &str) -> Result { + if method.is_empty() + || !method.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) + { + return Err(webidl::WebIdlError::custom_message("Request method is not a valid HTTP token")); + } + normalize_request_method(method) +} + pub(in crate::network_host) fn normalize_request_method( method: &str, ) -> Result { @@ -108,7 +136,7 @@ fn request_input_snapshot_inner<'s>( return Ok(None); }; let method = defined_object_string_property(scope, object, "method") - .map(|value| normalize_request_method(&value)) + .map(|value| normalize_fetch_request_method(&value)) .transpose()? .unwrap_or_else(|| "GET".to_owned()); let mode = @@ -169,7 +197,7 @@ fn request_input_snapshot_from_private_slots<'s>( ) -> Result { let url = request_slot_string(scope, object, REQUEST_URL_SLOT).unwrap_or_default(); let method = request_slot_string(scope, object, REQUEST_METHOD_SLOT) - .map(|value| normalize_request_method(&value)) + .map(|value| normalize_fetch_request_method(&value)) .transpose()? .unwrap_or_else(|| "GET".to_owned()); let mode = @@ -306,11 +334,11 @@ fn object_bool_property( .map(|value| value.boolean_value(scope)) } -pub(super) fn resolve_request_constructor_url( - scope: &mut v8::PinScope<'_, '_>, - input: &str, -) -> String { - try_resolve_request_constructor_url(scope, input).unwrap_or_else(|_| input.to_owned()) +pub(crate) 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")); + } + Ok(()) } pub(crate) fn try_resolve_request_constructor_url( @@ -378,19 +406,22 @@ pub(crate) fn try_resolve_request_constructor_url_for_scope( } } -pub(super) fn normalize_request_referrer(scope: &mut v8::PinScope<'_, '_>, input: &str) -> String { +pub(super) fn normalize_request_referrer( + scope: &mut v8::PinScope<'_, '_>, + input: &str, +) -> Result { if input.is_empty() || input == "about:client" { - return input.to_owned(); + return Ok(input.to_owned()); } - let resolved = resolve_request_constructor_url(scope, input); + let resolved = try_resolve_request_constructor_url(scope, input)?; let Some(context_url) = current_request_context_url(scope) else { - return resolved; + return Ok(resolved); }; if moli_url::parsed_same_origin(&resolved, &context_url) { - resolved + Ok(resolved) } else { - "about:client".to_owned() + Ok("about:client".to_owned()) } } diff --git a/moli-renderer-v8/src/worker/global_scope/fetch.rs b/moli-renderer-v8/src/worker/global_scope/fetch.rs index 0dccc4968c..bb0703cbf5 100644 --- a/moli-renderer-v8/src/worker/global_scope/fetch.rs +++ b/moli-renderer-v8/src/worker/global_scope/fetch.rs @@ -1873,7 +1873,8 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>( credentials_mode, redirect_mode, priority, - metadata, + mut metadata, + init_validation, ) = if let Some(inherited) = inherited { let req_obj = v8::Local::::try_from(arg0).expect("request-like object"); request_like = Some(req_obj); @@ -1900,7 +1901,8 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>( } let inherited_credentials = request_object_credentials_mode(scope, req_obj)?; let request_mode = init - .request_mode + .validation + .mode .or_else(|| moli_fetch::RequestMode::from_str(&inherited.mode).ok()) .unwrap_or(moli_fetch::RequestMode::Cors); validate_worker_no_cors_method(request_mode, &method)?; @@ -1923,7 +1925,7 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>( }); let metadata = ServiceWorkerFetchRequestMetadata { cache: init.cache.unwrap_or(inherited.cache), - referrer: init.referrer.unwrap_or(inherited.referrer), + referrer: inherited.referrer, referrer_policy: init.referrer_policy.unwrap_or(inherited.referrer_policy), integrity: init.integrity.unwrap_or(inherited.integrity), keepalive: init.keepalive.unwrap_or(inherited.keepalive), @@ -1938,6 +1940,7 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>( redirect_mode, priority, metadata, + init.validation, ) } else { let url = webidl::convert::( @@ -1947,7 +1950,10 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>( ) .map(String::from)?; let init = parse_fetch_init(scope, args, 1)?; - let request_mode = init.request_mode.unwrap_or(moli_fetch::RequestMode::Cors); + let request_mode = init + .validation + .mode + .unwrap_or(moli_fetch::RequestMode::Cors); validate_worker_no_cors_method(request_mode, &init.method)?; let headers = if request_mode == moli_fetch::RequestMode::NoCors { filter_headers_for_guard(&init.headers, HeadersGuard::RequestNoCors) @@ -1960,7 +1966,7 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>( let redirect_mode = init.redirect_mode.unwrap_or(RequestRedirectMode::Follow); let metadata = ServiceWorkerFetchRequestMetadata { cache: init.cache.unwrap_or_else(|| "default".to_owned()), - referrer: init.referrer.unwrap_or_else(|| "about:client".to_owned()), + referrer: "about:client".to_owned(), referrer_policy: init.referrer_policy.unwrap_or_default(), integrity: init.integrity.unwrap_or_default(), keepalive: init.keepalive.unwrap_or(false), @@ -1975,9 +1981,16 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>( redirect_mode, init.priority, metadata, + init.validation, ) }; let resolved_url = resolve_context_url(base_url, &url_input, None)?; + crate::network_host::validate_request_url_credentials(&resolved_url)?; + if let Some(referrer) = + init_validation.validate(scope, request_mode.as_ref(), &metadata.cache)? + { + metadata.referrer = referrer; + } let signal = worker_fetch_signal_option(scope, args, request_like)?; if consumes_request_body && let Some(request_like) = request_like { crate::network_host::mark_request_input_body_used_for_fetch(scope, request_like);