diff --git a/moli-renderer-v8/src/blob.rs b/moli-renderer-v8/src/blob.rs index 8bd26765e4..00efd06e99 100644 --- a/moli-renderer-v8/src/blob.rs +++ b/moli-renderer-v8/src/blob.rs @@ -1,7 +1,7 @@ use crate::context_bootstrap::media_source::{MediaSourceObject, media_source_object}; use crate::{native_bridge, web_api_interfaces}; use moli_file_api::{ - BlobId, BlobLineEndings, BlobStore, ObjectUrlData, ObjectUrlTarget, blob_slice_relative_index, + BlobId, BlobLineEndings, BlobStore, ObjectUrlEntry, ObjectUrlTarget, blob_slice_relative_index, clamp_blob_long_long, normalize_blob_line_endings_with_native_ending, normalize_blob_mime_type, }; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; @@ -43,12 +43,35 @@ struct BlobPrototypeDeclaration { r#type: (), } -#[derive(Debug, PartialEq, Eq)] -struct ObjectUrlAccessKey { +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ObjectUrlAccessKey { partition: RendererStoragePartitionIdentity, storage_key: moli_storage_key::MoliStorageKey, } +impl ObjectUrlAccessKey { + pub(crate) fn new( + partition: RendererStoragePartitionIdentity, + storage_key: moli_storage_key::MoliStorageKey, + ) -> Self { + Self { + partition, + storage_key, + } + } + + pub(crate) fn for_worker(scope: &mut v8::PinScope<'_, '_>) -> Option { + Some(Self::new( + crate::worker::worker_storage_partition_identity(scope)?, + crate::worker::worker_storage_key(scope)?, + )) + } + + pub(crate) fn same_partition(&self, other: &Self) -> bool { + self.partition == other.partition + } +} + type RendererBlobStore = BlobStore< ResourceOwnerId, RendererStoragePartitionIdentity, @@ -431,10 +454,10 @@ pub(super) fn create_object_url_for_object<'s>( ) } -pub(crate) type RendererObjectUrlData = ObjectUrlData>; +pub(crate) type RendererObjectUrlEntry = ObjectUrlEntry>; -pub(crate) fn object_url_data(url: &str) -> Option { - blob_store().object_url_data(url) +pub(crate) fn object_url_entry(url: &str) -> Option { + blob_store().object_url_entry(url) } pub(crate) fn object_url_policy(url: &str) -> Option { diff --git a/moli-renderer-v8/src/network/context/document.rs b/moli-renderer-v8/src/network/context/document.rs index d69185361d..e1c038b209 100644 --- a/moli-renderer-v8/src/network/context/document.rs +++ b/moli-renderer-v8/src/network/context/document.rs @@ -80,6 +80,7 @@ impl Drop for DocumentResourceLoaderAuthority { #[derive(Clone)] pub struct DocumentResourceLoader { request_client: ResourceRequestClient, + creation_url: Arc, authority: Arc, csp_reports: Arc, } @@ -156,6 +157,7 @@ impl DocumentResourceLoader { .with_document_preloads(preloads.clone()); Self { request_client, + creation_url: Arc::new(context.document_url().clone()), csp_reports: Default::default(), authority: Arc::new(DocumentResourceLoaderAuthority { id: NEXT_DOCUMENT_RESOURCE_LOADER_ID @@ -214,6 +216,7 @@ impl DocumentResourceLoader { // document.open() rotates Moli's load owner, but keeps the Document's // CSP. In-flight requests and new requests must share its report history. replacement.csp_reports = previous.csp_reports.clone(); + replacement.creation_url = previous.creation_url.clone(); // document.open() replaces the load owner, not the Document. Transfer // retirement authority along with its one-shot preload map. previous @@ -268,6 +271,7 @@ impl DocumentResourceLoader { Self { request_client, authority: Arc::clone(&self.authority), + creation_url: self.creation_url.clone(), csp_reports: self.csp_reports.clone(), } } @@ -316,6 +320,12 @@ impl DocumentResourceLoader { self.authority.lifecycle.lock().context.clone() } + /// The settings object's creation URL is unaffected by history changes, + /// base URL changes, transport replacement, or document.open(). + pub(crate) fn creation_url(&self) -> &url::Url { + &self.creation_url + } + /// Updates the URL inputs for later loads without changing this Document's /// origin, authority, or requests that already captured their settings. pub(crate) fn update_document_urls(&self, document_url: url::Url, base_url: url::Url) { diff --git a/moli-renderer-v8/src/network/context/tests.rs b/moli-renderer-v8/src/network/context/tests.rs index dba7617861..79ba09b428 100644 --- a/moli-renderer-v8/src/network/context/tests.rs +++ b/moli-renderer-v8/src/network/context/tests.rs @@ -41,6 +41,30 @@ fn resource_task_runner() -> crate::network::RendererResourceTaskRunner { .expect("resource authority test must own a Tokio runtime") } +#[tokio::test(flavor = "current_thread")] +async fn document_creation_url_survives_history_transport_and_document_open() { + let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let first = document_loader(transport.clone(), 1, "blob:https://example.test/creation"); + let creation = first.creation_url().clone(); + let changed = Url::parse("blob:https://example.test/history").unwrap(); + first.update_document_urls(changed.clone(), changed.clone()); + assert_eq!(first.fetch_context().document_url(), &changed); + assert_eq!(first.creation_url(), &creation); + let replacement = first.with_replacement_transport(transport.handle()); + assert_eq!(replacement.creation_url(), &creation); + let opened = DocumentResourceLoader::for_document_open( + context(2, changed.as_str()), + super::document::DocumentResourceAuthoritySource::Inherited(replacement), + &first, + ); + assert_eq!(opened.creation_url(), &creation); + let navigated = first.fork_for_document(context(3, "https://example.test/navigated")); + assert_eq!( + navigated.creation_url().as_str(), + "https://example.test/navigated" + ); +} + fn document_loader( request_client: ResourceRequestClient, document_id: u64, diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index 51b536e81d..e6b047292a 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -30,7 +30,9 @@ use moli_webapi_declare::WebApiObject; use crate::network::ResourceRequestClient; pub(in crate::network_host) use self::blob_url_entry::BLOB_URL_ENTRY_SLOT; -pub(crate) use self::blob_url_entry::{CapturedBlobUrl, blob_url_entry, set_blob_url_entry}; +pub(crate) use self::blob_url_entry::{ + BlobUrlFetchEnvironment, CapturedBlobUrl, blob_url_entry, set_blob_url_entry, +}; pub(crate) use self::async_fetch::{ collect_image_response_into_parkable, @@ -160,7 +162,7 @@ pub(crate) use self::request_scope::{ pub(crate) use self::response::{ FetchResponseRequest, FetchResponseSecurityViolation, MaterializedResponseBody, MaterializedResponseHead, build_error_response_object, - build_fetch_response_object_for_request_mode, + build_fetch_response_object_for_request_mode_with_filter, build_fetch_response_object_from_body_source_for_request_mode_with_filter, build_fetch_response_object_from_stream_for_request_mode_with_filter, build_fetch_response_object_from_subresource_body_for_request_mode_with_filter, diff --git a/moli-renderer-v8/src/network_host/blob_url_entry.rs b/moli-renderer-v8/src/network_host/blob_url_entry.rs index 2cad2a33ff..b7c3b13c68 100644 --- a/moli-renderer-v8/src/network_host/blob_url_entry.rs +++ b/moli-renderer-v8/src/network_host/blob_url_entry.rs @@ -3,18 +3,60 @@ //! XHR. Private carriers retain the Blob bytes or native MediaSource identity //! until their last JS owner is collected; isolate teardown also releases them. -use std::{cell::RefCell, collections::HashMap, rc::Rc}; +use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; use crate::util::{get_private_value, set_private_value}; use moli_file_api::ObjectUrlData; +/// Fetch authority comes from the pinned execution context, never from the +/// serialized blob URL or the realm in which an argument getter runs. +pub(crate) struct BlobUrlFetchEnvironment { + access_key: crate::blob::ObjectUrlAccessKey, + top_level_creation_url: Option, +} + +impl BlobUrlFetchEnvironment { + pub(crate) fn for_window( + host: &mut crate::native_bridge::JsContextHost, + binding: &crate::native_bridge::WindowExecutionContextBinding, + ) -> Option { + let identity = binding.resolve_identity(host)?; + let storage_context = + host.storage_context_for_window_execution_context_identity(identity)?; + let dispatch_scope = identity.dispatch_scope(); + let top_level_creation_url = match dispatch_scope { + crate::native_bridge::OwnerDispatchScope::Child(_) => None, + crate::native_bridge::OwnerDispatchScope::Top + | crate::native_bridge::OwnerDispatchScope::LightweightPopup(_) => { + let loader = host.document_resource_loader_for_dispatch_scope(dispatch_scope)?; + Some(loader.creation_url().clone()) + } + }; + Some(Self { + access_key: crate::blob::ObjectUrlAccessKey::new( + host.browser_context_runtime().storage_partition_identity(), + storage_context.storage_key().clone(), + ), + top_level_creation_url, + }) + } + + pub(crate) fn for_worker(scope: &mut v8::PinScope<'_, '_>) -> Option { + Some(Self { + access_key: crate::blob::ObjectUrlAccessKey::for_worker(scope)?, + top_level_creation_url: None, + }) + } +} + pub(in crate::network_host) const BLOB_URL_ENTRY_SLOT: &str = "__lmBlobUrlEntry"; const ID_SLOT: &str = "__lmBlobUrlEntryId"; #[derive(Clone)] pub(crate) struct CapturedBlobUrl { url: url::Url, - data: Option, + entry: Option>, + fetch_authorized: bool, } impl CapturedBlobUrl { @@ -22,10 +64,38 @@ impl CapturedBlobUrl { if url.scheme() != "blob" { return None; } - let mut url = url.clone(); - url.set_fragment(None); - let data = crate::blob::object_url_data(url.as_str()); - Some(Self { url, data }) + let url = url.clone(); + let entry = crate::blob::object_url_entry(url.as_str()).map(Arc::new); + Some(Self { + url, + entry, + fetch_authorized: false, + }) + } + + /// Scope a copy for this fetch without changing the parsed Request/XHR + /// entry. A denied lookup stays captured and must not fall back to a fresh + /// unrestricted lookup in the store. + pub(crate) fn for_environment(mut self, environment: Option<&BlobUrlFetchEnvironment>) -> Self { + self.fetch_authorized = self.entry.as_ref().is_some_and(|entry| { + environment.is_some_and(|environment| { + entry.access_key.as_ref().is_some_and(|key| { + key == &environment.access_key + // Fetch explicitly exempts a top-level document fetching + // its own creation URL from storage-key partition checks. + || (key.same_partition(&environment.access_key) + && environment.top_level_creation_url.as_ref() == Some(&self.url)) + }) + }) + }); + if !self.fetch_authorized { + self.entry = None; + } + self + } + + pub(crate) fn is_authorized_fetch(&self, url: &url::Url) -> bool { + self.fetch_authorized && self.matches(url) } pub(super) fn response( @@ -36,7 +106,7 @@ impl CapturedBlobUrl { let ObjectUrlData::Blob { bytes: body, mime_type, - } = self.data.as_ref()? + } = &self.entry.as_ref()?.data else { // Fetch's blob scheme only reads Blob objects, never MediaSource. return Some(Err(super::browser_response::LocalUrlError::MediaSource)); @@ -50,7 +120,10 @@ impl CapturedBlobUrl { } pub(super) fn matches(&self, url: &url::Url) -> bool { - self.url.as_str() + self.url + .as_str() + .split_once('#') + .map_or(self.url.as_str(), |(url, _)| url) == url .as_str() .split_once('#') @@ -140,7 +213,8 @@ mod tests { use std::sync::Arc; fn bytes(entry: &CapturedBlobUrl) -> &Arc<[u8]> { - let Some(ObjectUrlData::Blob { bytes, .. }) = &entry.data else { + let Some(ObjectUrlData::Blob { bytes, .. }) = entry.entry.as_ref().map(|entry| &entry.data) + else { panic!("expected captured Blob bytes"); }; bytes @@ -149,13 +223,165 @@ mod tests { fn entry() -> CapturedBlobUrl { CapturedBlobUrl { url: url::Url::parse("blob:https://example.test/captured").unwrap(), - data: Some(ObjectUrlData::Blob { - bytes: Arc::from([0, 128, 255]), - mime_type: "application/example".to_owned(), - }), + entry: Some(Arc::new(moli_file_api::ObjectUrlEntry { + data: ObjectUrlData::Blob { + bytes: Arc::from([0, 128, 255]), + mime_type: "application/example".to_owned(), + }, + access_key: None, + })), + fetch_authorized: false, } } + #[test] + fn fetch_access_uses_full_creator_identity_without_mutating_the_parsed_entry() { + use crate::runtime::RendererBrowserContextRuntime; + use moli_storage_key::{MoliStorageKey, OpaqueOriginNonce}; + let browser = RendererBrowserContextRuntime::new(); + let partition = browser.handle().storage_partition_identity(); + let url = url::Url::parse("data:text/plain,opaque").unwrap(); + let storage_key = + MoliStorageKey::first_party_from_url(&url, Some(OpaqueOriginNonce::new(101))); + let creator_key = + crate::blob::ObjectUrlAccessKey::new(partition.clone(), storage_key.clone()); + let mut captured = entry(); + captured.url = url::Url::parse("blob:null/captured").unwrap(); + Arc::get_mut(captured.entry.as_mut().unwrap()) + .unwrap() + .access_key = Some(creator_key.clone()); + let own = BlobUrlFetchEnvironment { + access_key: creator_key, + top_level_creation_url: None, + }; + let other_browser = RendererBrowserContextRuntime::new(); + let foreign_keys = [ + crate::blob::ObjectUrlAccessKey::new( + other_browser.handle().storage_partition_identity(), + storage_key.clone(), + ), + crate::blob::ObjectUrlAccessKey::new( + partition.clone(), + MoliStorageKey::first_party_from_url(&url, Some(OpaqueOriginNonce::new(102))), + ), + crate::blob::ObjectUrlAccessKey::new( + partition.clone(), + MoliStorageKey::from_url_and_top_level_site( + &url, + "https://another-site.test".to_owned(), + storage_key.opaque_nonce(), + ), + ), + crate::blob::ObjectUrlAccessKey::new(partition, storage_key.with_cross_site_ancestor()), + ]; + for access_key in foreign_keys { + let environment = BlobUrlFetchEnvironment { + access_key, + top_level_creation_url: None, + }; + let denied = captured.clone().for_environment(Some(&environment)); + assert!(!denied.is_authorized_fetch(&captured.url)); + assert!( + super::super::local_url_response_with_blob_entry( + &captured.url, + "GET", + &[], + Some(&denied) + ) + .unwrap() + .is_err() + ); + } + assert!( + captured + .clone() + .for_environment(None) + .response(&captured.url, &[]) + .is_none() + ); + let authorized = captured.clone().for_environment(Some(&own)); + assert!(authorized.is_authorized_fetch(&captured.url)); + assert_eq!( + authorized + .response(&captured.url, &[]) + .unwrap() + .unwrap() + .body_bytes(), + [0, 128, 255] + ); + assert!(captured.entry.is_some()); + assert!(!captured.fetch_authorized); + let mut fragment = captured.url.clone(); + fragment.set_fragment(Some("fragment")); + assert!(authorized.is_authorized_fetch(&fragment)); + assert!(!authorized.is_authorized_fetch(&url)); + } + + #[test] + fn top_level_self_fetch_exception_does_not_cross_browser_partitions_or_cover_other_urls() { + use crate::runtime::RendererBrowserContextRuntime; + use moli_storage_key::MoliStorageKey; + let browser = RendererBrowserContextRuntime::new(); + let partition = browser.handle().storage_partition_identity(); + let url = url::Url::parse("https://example.test/").unwrap(); + let creator = MoliStorageKey::first_party_from_url(&url, None); + let embedded = MoliStorageKey::from_url_and_top_level_site( + &url, + "https://foreign.test".to_owned(), + None, + ); + let mut captured = entry(); + Arc::get_mut(captured.entry.as_mut().unwrap()) + .unwrap() + .access_key = Some(crate::blob::ObjectUrlAccessKey::new( + partition.clone(), + embedded, + )); + let mut environment = BlobUrlFetchEnvironment { + access_key: crate::blob::ObjectUrlAccessKey::new(partition, creator.clone()), + top_level_creation_url: Some(captured.url.clone()), + }; + assert!( + captured + .clone() + .for_environment(Some(&environment)) + .is_authorized_fetch(&captured.url) + ); + let mut different_fragment = captured.clone(); + different_fragment.url.set_fragment(Some("other")); + assert!( + !different_fragment + .clone() + .for_environment(Some(&environment)) + .is_authorized_fetch(&different_fragment.url) + ); + environment.top_level_creation_url = Some(url); + assert!( + !captured + .clone() + .for_environment(Some(&environment)) + .is_authorized_fetch(&captured.url) + ); + environment.top_level_creation_url = Some(captured.url.clone()); + let other_browser = RendererBrowserContextRuntime::new(); + environment.access_key = crate::blob::ObjectUrlAccessKey::new( + other_browser.handle().storage_partition_identity(), + creator, + ); + assert!( + !captured + .clone() + .for_environment(Some(&environment)) + .is_authorized_fetch(&captured.url) + ); + let unkeyed = entry(); + assert!( + !unkeyed + .for_environment(Some(&environment)) + .is_authorized_fetch(&captured.url) + ); + } + #[test] fn private_entries_follow_the_last_owner_across_realms_gc_and_isolate_drop() { moli_v8_test_util::ensure_v8(); @@ -232,7 +458,11 @@ mod tests { .unwrap() .is_err() ); - let missing = CapturedBlobUrl { url, data: None }; + let missing = CapturedBlobUrl { + url, + entry: None, + fetch_authorized: false, + }; assert!(missing.response(&missing.url, &[]).is_none()); } } diff --git a/moli-renderer-v8/src/network_host/fetch/bindings.rs b/moli-renderer-v8/src/network_host/fetch/bindings.rs index 1581b39c2b..e47df2d5db 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings.rs @@ -176,6 +176,14 @@ pub(crate) fn window_fetch_callback<'s>( ); return; }; + let Some(blob_environment) = + BlobUrlFetchEnvironment::for_window(unsafe { &mut *host_ptr }, &binding) + else { + rv.set( + make_rejected_promise(scope, "fetch: document execution context is unavailable").into(), + ); + return; + }; let fetch_context = crate::native_bridge::WindowFetchContext::from_realm(binding); let relevant_context = { let context = fetch_context.script_realm().context(scope); @@ -185,7 +193,13 @@ pub(crate) fn window_fetch_callback<'s>( 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 }) + prepare_window_fetch_request( + scope, + parsed, + fetch_context, + unsafe { &*host_ptr }, + &blob_environment, + ) }; let prepared = match prepared { Ok(prepared) => prepared, @@ -262,8 +276,13 @@ fn window_fetch_callback_in_relevant_realm<'s>( return; } - if let Err(message) = moli_fetch::FetchUrlList::new(&prepared.resolved_url, &[]) - .validate_request_mode(prepared.request_mode, &prepared.request_origin) + let authorized_blob = prepared + .blob_url_entry + .as_ref() + .is_some_and(|entry| entry.is_authorized_fetch(&prepared.resolved_url)); + if !authorized_blob + && let Err(message) = moli_fetch::FetchUrlList::new(&prepared.resolved_url, &[]) + .validate_request_mode(prepared.request_mode, &prepared.request_origin) { rv.set(make_rejected_promise(scope, &message).into()); return; @@ -295,7 +314,7 @@ fn window_fetch_callback_in_relevant_realm<'s>( // Renderer-owned URLs, including local errors, bypass network interception. match resolve_local_fetch(host, &prepared) { Ok(Some(response)) => { - let response_obj = build_fetch_response_object_for_request_mode( + let response_obj = build_fetch_response_object_for_request_mode_with_filter( scope, &prepared.request_origin, FetchResponseRequest { @@ -304,6 +323,7 @@ fn window_fetch_callback_in_relevant_realm<'s>( redirect_mode: prepared.redirect_mode, }, response, + authorized_blob.then_some(crate::types::AsyncSubresourceFetchResponseFilter::Basic), ); resolver.resolve(scope, response_obj.into()); rv.set(promise.into()); diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs b/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs index 2dbb3c248a..5507b875bd 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs @@ -175,16 +175,24 @@ pub(super) fn resolve_local_fetch( .map_err(LocalFetchError::Url) .and_then(|response| { if !prepared.integrity.is_empty() { - let filter = FetchResponseRequest { - method: &prepared.method, - mode: prepared.request_mode, - redirect_mode: prepared.redirect_mode, - } - .network_response_filter( - &prepared.request_origin, - &response.head(), - prepared.credentials_mode, - ); + let filter = if prepared + .blob_url_entry + .as_ref() + .is_some_and(|entry| entry.is_authorized_fetch(&prepared.resolved_url)) + { + crate::types::AsyncSubresourceFetchResponseFilter::Basic + } else { + FetchResponseRequest { + method: &prepared.method, + mode: prepared.request_mode, + redirect_mode: prepared.redirect_mode, + } + .network_response_filter( + &prepared.request_origin, + &response.head(), + prepared.credentials_mode, + ) + }; validate_fetch_response_integrity( &prepared.integrity, &prepared.method, 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 7e59ea6e50..3a0e62ae27 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/request.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/request.rs @@ -100,6 +100,7 @@ pub(super) fn prepare_window_fetch_request<'s>( parsed: ParsedWindowFetchInput, fetch_context: crate::native_bridge::WindowFetchContext, host: &JsContextHost, + blob_environment: &BlobUrlFetchEnvironment, ) -> Result { // Match Request's Headers representation before preflight and context // overrides: repeated Range fields form one unsafe, comma-joined value. @@ -164,7 +165,8 @@ pub(super) fn prepare_window_fetch_request<'s>( let blob_url_entry = parsed .blob_url_entry - .or_else(|| CapturedBlobUrl::capture(&resolved_url)); + .or_else(|| CapturedBlobUrl::capture(&resolved_url)) + .map(|entry| entry.for_environment(Some(blob_environment))); Ok(PreparedWindowFetchRequest { frame_id, fetch_context, diff --git a/moli-renderer-v8/src/network_host/response.rs b/moli-renderer-v8/src/network_host/response.rs index 5e02b3bd10..f8f5900c96 100644 --- a/moli-renderer-v8/src/network_host/response.rs +++ b/moli-renderer-v8/src/network_host/response.rs @@ -29,7 +29,7 @@ pub(crate) use self::cors::{ }; pub(crate) use self::materialize::{ FetchResponseRequest, MaterializedResponseBody, MaterializedResponseHead, - build_fetch_response_object_for_request_mode, + build_fetch_response_object_for_request_mode_with_filter, build_fetch_response_object_from_body_source_for_request_mode_with_filter, build_fetch_response_object_from_stream_for_request_mode_with_filter, build_fetch_response_object_from_subresource_body_for_request_mode_with_filter, diff --git a/moli-renderer-v8/src/network_host/response/materialize.rs b/moli-renderer-v8/src/network_host/response/materialize.rs index ed86bd077d..cfced15f59 100644 --- a/moli-renderer-v8/src/network_host/response/materialize.rs +++ b/moli-renderer-v8/src/network_host/response/materialize.rs @@ -211,38 +211,22 @@ fn filtered_response_status_text( } } -pub(crate) fn build_fetch_response_object_for_request_mode<'s>( +pub(crate) fn build_fetch_response_object_for_request_mode_with_filter<'s>( scope: &mut v8::PinScope<'s, '_>, request_origin: impl Into, request: FetchResponseRequest<'_>, response: Response, + filter_override: Option, ) -> v8::Local<'s, v8::Object> { let request_origin = request_origin.into(); let (head, body) = response.into_body(); - build_fetch_response_object_from_body_source_for_request_mode( - scope, - &request_origin, - request, - head, - body, - ) -} - -pub(crate) fn build_fetch_response_object_from_body_source_for_request_mode<'s>( - scope: &mut v8::PinScope<'s, '_>, - request_origin: impl Into, - request: FetchResponseRequest<'_>, - head: moli_fetch::ResponseHead, - body: moli_fetch::ResponseBody, -) -> v8::Local<'s, v8::Object> { - let request_origin = request_origin.into(); build_fetch_response_object_from_body_source_for_request_mode_with_filter( scope, &request_origin, request, head, body, - None, + filter_override, ) } diff --git a/moli-renderer-v8/src/network_host/xhr/send/request.rs b/moli-renderer-v8/src/network_host/xhr/send/request.rs index d2667c645d..f2e402e8d0 100644 --- a/moli-renderer-v8/src/network_host/xhr/send/request.rs +++ b/moli-renderer-v8/src/network_host/xhr/send/request.rs @@ -58,7 +58,7 @@ pub(super) fn xhr_dom_debugger_request_url<'s>( pub(super) fn prepare_xhr_send_request<'s>( scope: &mut v8::PinScope<'s, '_>, - host: &JsContextHost, + host: &mut JsContextHost, xhr: v8::Local<'s, v8::Object>, method: String, prepared_body: PreparedXhrSendBody, @@ -67,6 +67,8 @@ pub(super) fn prepare_xhr_send_request<'s>( let execution_context = xhr_execution_context_binding(scope, host, xhr) .ok_or(XhrSendPrepareError::ExecutionContext)?; + let blob_environment = BlobUrlFetchEnvironment::for_window(host, &execution_context) + .ok_or(XhrSendPrepareError::ExecutionContext)?; let owner = execution_context.dispatch_scope(); let resource_loader = host .document_resource_loader_for_dispatch_scope(owner) @@ -105,7 +107,8 @@ pub(super) fn prepare_xhr_send_request<'s>( document_referrer_policy, policy_context, resolved_url, - blob_url_entry: blob_url_entry(scope, xhr), + blob_url_entry: blob_url_entry(scope, xhr) + .map(|entry| entry.for_environment(Some(&blob_environment))), method, request_headers, cors_preflight_request_headers, diff --git a/moli-renderer-v8/src/script_vm/tests/blob_storage_key.rs b/moli-renderer-v8/src/script_vm/tests/blob_storage_key.rs new file mode 100644 index 0000000000..fe0bcb6dae --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/blob_storage_key.rs @@ -0,0 +1,34 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn blob_url_fetch_and_xhr_use_creator_storage_keys_in_window_and_worker() { + let loader = static_http_loader([]); + let mut vm = new_page_task_executor_test_vm_with_loader("https://blob-storage.test/", &loader); + let fixture = include_str!("../../../tests/fixtures/blob-storage-key.js"); + vm.eval(&format!( + "globalThis.blobStorageResult = null; {fixture}.then(value => {{blobStorageResult = value;}}, error => {{blobStorageResult = String(error.stack || error);}});" + )).unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(blobStorageResult !== null)", + "true", + "Blob storage-key probes should finish without network access", + ) + .await; + let result: serde_json::Value = + serde_json::from_str(&vm.eval("JSON.stringify(__uiEventResults)").unwrap()).unwrap(); + let checks = result["checks"].as_array().unwrap(); + let failures: Vec<_> = checks + .iter() + .filter(|check| check["passed"] != true) + .collect(); + assert_eq!(checks.len(), 46); + assert_eq!(result["complete"], true); + assert_eq!( + vm.eval("String(blobStorageResult)").unwrap(), + "true", + "{failures:?}" + ); + assert!(failures.is_empty(), "{failures:?}"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 741d67e974..ac62ed7451 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2217,6 +2217,7 @@ mod audio_source_interfaces; mod bitmap_rendering_context_interface_interfaces; mod blob_range; mod blob_response_headers; +mod blob_storage_key; mod body_completion; mod body_mime; mod body_mime_consolidation; diff --git a/moli-renderer-v8/src/script_vm/tests/webidl_fetch/url_and_initializer_semantics.rs b/moli-renderer-v8/src/script_vm/tests/webidl_fetch/url_and_initializer_semantics.rs index bc71f9985f..a5206db389 100644 --- a/moli-renderer-v8/src/script_vm/tests/webidl_fetch/url_and_initializer_semantics.rs +++ b/moli-renderer-v8/src/script_vm/tests/webidl_fetch/url_and_initializer_semantics.rs @@ -975,32 +975,34 @@ fn materialize_response_object_preserves_redirected_slot() { let scope = &mut scope.init(); let context = unsafe { v8::Local::new(scope, &*context_ptr) }; let scope = &mut v8::ContextScope::new(scope, context); - let response = crate::network_host::build_fetch_response_object_for_request_mode( - scope, - &document_url, - crate::network_host::FetchResponseRequest { - redirect_mode: moli_fetch::RequestRedirectMode::Follow, - method: "GET", - mode: moli_fetch::RequestMode::Cors, - }, - moli_fetch::Response::from_head_and_text_body( - moli_fetch::ResponseHead { - status_text: None, - final_url: final_url.clone(), - status: 200, - headers: vec![("content-type".to_owned(), b"text/plain".to_vec())], - request_cookie_report: None, - cookie_set_reports: Vec::new(), - redirected: true, - redirect_chain: Vec::new(), - from_cache: false, - cache_state: Default::default(), - preload_state: Default::default(), - negotiated_http_version: None, + let response = + crate::network_host::build_fetch_response_object_for_request_mode_with_filter( + scope, + &document_url, + crate::network_host::FetchResponseRequest { + redirect_mode: moli_fetch::RequestRedirectMode::Follow, + method: "GET", + mode: moli_fetch::RequestMode::Cors, }, - "redirected-body".to_owned(), - ), - ); + moli_fetch::Response::from_head_and_text_body( + moli_fetch::ResponseHead { + status_text: None, + final_url: final_url.clone(), + status: 200, + headers: vec![("content-type".to_owned(), b"text/plain".to_vec())], + request_cookie_report: None, + cookie_set_reports: Vec::new(), + redirected: true, + redirect_chain: Vec::new(), + from_cache: false, + cache_state: Default::default(), + preload_state: Default::default(), + negotiated_http_version: None, + }, + "redirected-body".to_owned(), + ), + None, + ); let materialized = crate::network_host::materialize_response_object(scope, response.into(), "test") .expect("materialized response should be accepted"); @@ -1382,9 +1384,10 @@ redirect_mode: moli_fetch::RequestRedirectMode::Follow, }; let id = crate::network_host::new_network_body_source_id(); let response = match source { - "response" => crate::network_host::build_fetch_response_object_for_request_mode( + "response" => crate::network_host::build_fetch_response_object_for_request_mode_with_filter( scope, &document_url, request, moli_fetch::Response::from_head_and_text_body(head, "discard me".to_owned()), + None, ), "bytes" => crate::network_host::build_fetch_response_object_from_body_source_for_request_mode_with_filter( scope, &document_url, request, head, diff --git a/moli-renderer-v8/src/worker/global_scope/fetch.rs b/moli-renderer-v8/src/worker/global_scope/fetch.rs index 2996b1683c..f755da941d 100644 --- a/moli-renderer-v8/src/worker/global_scope/fetch.rs +++ b/moli-renderer-v8/src/worker/global_scope/fetch.rs @@ -1,6 +1,6 @@ use super::*; use crate::network_host::{ - CapturedBlobUrl, FetchArgumentError, convert_fetch_arguments, + BlobUrlFetchEnvironment, CapturedBlobUrl, FetchArgumentError, convert_fetch_arguments, local_url_response_with_blob_entry, normalized_headers_entries, }; use crate::service_worker_runtime::{ @@ -222,6 +222,9 @@ pub(in crate::worker) fn spawn_worker_fetch_network( ) { // Resolve local URLs before scheduling: fetch(url) already parsed and // captured its entry when JavaScript regains control and may revoke it. + let authorized_blob = blob_url_entry + .as_ref() + .is_some_and(|entry| entry.is_authorized_fetch(&resolved_url)); let local_response = local_url_response_with_blob_entry( &resolved_url, &method, @@ -230,8 +233,8 @@ pub(in crate::worker) fn spawn_worker_fetch_network( ); tokio::task::spawn_local(async move { let loader = load.request_client(); - let (result, network_request_headers) = if let Err(message) = - moli_fetch::FetchUrlList::new(&resolved_url, &[]) + let (result, network_request_headers) = if !authorized_blob + && let Err(message) = moli_fetch::FetchUrlList::new(&resolved_url, &[]) .validate_request_mode(request_mode, &moli_url::WebOrigin::from_url(&document_url)) { (Err(message), None) @@ -399,8 +402,9 @@ pub(in crate::worker) fn spawn_worker_fetch_network( }; let _ = completion_tx.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { - response_filter: None, - skip_fetch_security_validation: false, + response_filter: authorized_blob + .then_some(crate::types::AsyncSubresourceFetchResponseFilter::Basic), + skip_fetch_security_validation: authorized_blob, fetch_id, network_request_headers, result, @@ -2279,6 +2283,9 @@ pub(super) fn start_worker_resource_fetch<'s>( metadata: request_metadata, signal, } = input; + let blob_environment = BlobUrlFetchEnvironment::for_worker(scope); + let blob_url_entry = + blob_url_entry.map(|entry| entry.for_environment(blob_environment.as_ref())); // Use the same normalized header list as Request before policy checks and // context overrides can otherwise discard a repeated Range field. let request_headers = normalized_headers_entries(&request_headers); @@ -2378,8 +2385,11 @@ pub(super) fn start_worker_resource_fetch<'s>( return Some(make_rejected_promise(scope, &message)); } - if let Err(message) = moli_fetch::FetchUrlList::new(&resolved_url, &[]) - .validate_request_mode(request_mode, &moli_url::WebOrigin::from_url(&document_url)) + if !blob_url_entry + .as_ref() + .is_some_and(|entry| entry.is_authorized_fetch(&resolved_url)) + && let Err(message) = moli_fetch::FetchUrlList::new(&resolved_url, &[]) + .validate_request_mode(request_mode, &moli_url::WebOrigin::from_url(&document_url)) { return Some(make_rejected_promise(scope, &message)); } diff --git a/moli-renderer-v8/src/worker/global_scope/xhr.rs b/moli-renderer-v8/src/worker/global_scope/xhr.rs index 207f4afe81..563d0219f4 100644 --- a/moli-renderer-v8/src/worker/global_scope/xhr.rs +++ b/moli-renderer-v8/src/worker/global_scope/xhr.rs @@ -1213,6 +1213,7 @@ pub(in crate::worker) fn prepare_worker_xhr_send_request<'s>( let resolved_url = resolve_context_url(&document_url, &url_str, None) .map_err(WorkerXhrSendPrepareError::Url)?; let request_headers = xhr_author_request_headers(scope, xhr, &prepared_body); + let blob_environment = crate::network_host::BlobUrlFetchEnvironment::for_worker(scope); let credentials_mode = if xhr_state_bool_property(scope, xhr, XHR_WITH_CREDENTIALS_SLOT).unwrap_or(false) { RequestCredentialsMode::Include @@ -1224,7 +1225,8 @@ pub(in crate::worker) fn prepare_worker_xhr_send_request<'s>( use_cors_preflight: capture_xhr_upload_listener_flag(scope, xhr), document_url, resolved_url, - blob_url_entry: blob_url_entry(scope, xhr), + blob_url_entry: blob_url_entry(scope, xhr) + .map(|entry| entry.for_environment(blob_environment.as_ref())), method, request_headers: moli_fetch::RequestHeaders::from_byte_strings(&request_headers) .expect("validated worker XHR headers are ByteStrings"), diff --git a/moli-renderer-v8/tests/fixtures/blob-storage-key.js b/moli-renderer-v8/tests/fixtures/blob-storage-key.js new file mode 100644 index 0000000000..e6315fcbbe --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/blob-storage-key.js @@ -0,0 +1,79 @@ +(async () => { + const checks = []; + const check = (name, passed, observed) => checks.push({name, passed, observed}); + const modes = ['cors', 'no-cors', 'same-origin']; + async function read(url, mode) { + try { + const r = await fetch(url, {mode}); + return {type: r.type, status: r.status, text: await r.text()}; + } catch (e) { return {error: e.name}; } + } + function xhr(url) { + return new Promise(resolve => { + const x = new XMLHttpRequest(); x.open('GET', url); + x.onload = () => resolve({status: x.status, text: x.responseText}); + x.onerror = () => resolve({error: 'NetworkError', status: x.status}); x.send(); + }); + } + const peerSource = `const read=${read.toString()}; const xhr=${xhr.toString()}; + const own=URL.createObjectURL(new Blob(['peer'],{type:'text/plain'})); + const tell=x=>${'typeof parent !== "undefined" ? parent.postMessage(x,"*") : postMessage(x)'}; + tell({kind:'ready',url:own}); + onmessage=async e=>{if(e.data.kind!=='audit')return; + const rows=[];for(const [name,url] of [['own',own],['parent',e.data.parent],['foreign',e.data.foreign]]){ + for(const mode of ['cors','no-cors','same-origin'])rows.push({name,mode,...await read(url,mode)}); + rows.push({name,mode:'xhr',...await xhr(url)}); + }tell({kind:'done',rows});};`; + const wait = (source, kind) => new Promise(resolve => { + const listener = e => {if(e.source===source && e.data?.kind===kind){removeEventListener('message',listener);resolve(e.data);}}; + addEventListener('message',listener); + }); + const opaque = document.createElement('iframe'); opaque.sandbox='allow-scripts'; + opaque.srcdoc='