From 199b8ee1448b98d2eb845d4f70de2aeda0bfc9d8 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 6 Oct 2026 15:19:48 +0800 Subject: [PATCH] fix(fetch): enforce blob URL storage-key access Authorize captured entries using pinned Window/XHR owners and Worker storage keys. Preserve Request entries when another environment is denied, and use Basic responses for authorized reads, including opaque origins and integrity checks. Keep Document creation URLs stable across history, transport replacement, and document.open() so self-fetch exemptions remain tied to the original environment. Validation: cargo fmt --all; strict workspace/all-targets/all-features Clippy; 21465 nextest tests passed, 16 skipped, 32 threads and zero retries. --- moli-renderer-v8/src/blob.rs | 35 ++- .../src/network/context/document.rs | 10 + moli-renderer-v8/src/network/context/tests.rs | 24 ++ moli-renderer-v8/src/network_host.rs | 6 +- .../src/network_host/blob_url_entry.rs | 258 +++++++++++++++++- .../src/network_host/fetch/bindings.rs | 28 +- .../src/network_host/fetch/bindings/paths.rs | 28 +- .../network_host/fetch/bindings/request.rs | 4 +- moli-renderer-v8/src/network_host/response.rs | 2 +- .../src/network_host/response/materialize.rs | 22 +- .../src/network_host/xhr/send/request.rs | 7 +- .../src/script_vm/tests/blob_storage_key.rs | 34 +++ moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../url_and_initializer_semantics.rs | 55 ++-- .../src/worker/global_scope/fetch.rs | 24 +- .../src/worker/global_scope/xhr.rs | 4 +- .../tests/fixtures/blob-storage-key.js | 79 ++++++ 17 files changed, 528 insertions(+), 93 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/blob_storage_key.rs create mode 100644 moli-renderer-v8/tests/fixtures/blob-storage-key.js 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='