diff --git a/moli-renderer-v8/src/context_bootstrap/window_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/window_runtime/navigator.rs index e34e35c8cd..ddf887ed86 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_runtime/navigator.rs @@ -64,6 +64,8 @@ const STORAGE_BUCKET_CACHE_PUT_RESPONSE_STATUS_SLOT: &str = "__moliStorageBucketCachePutResponseStatus"; const STORAGE_BUCKET_CACHE_PUT_RESPONSE_STATUS_TEXT_SLOT: &str = "__moliStorageBucketCachePutResponseStatusText"; +const STORAGE_BUCKET_CACHE_PUT_RESPONSE_CORS_EXPOSED_HEADERS_SLOT: &str = + "__lmStorageBucketCachePutResponseCorsExposedHeaders"; const STORAGE_BUCKET_CACHE_PUT_RESPONSE_HEADERS_SLOT: &str = "__moliStorageBucketCachePutResponseHeaders"; const NAVIGATOR_UA_DATA_USER_AGENT_SLOT: &str = "__moliNavigatorUADataUserAgent"; @@ -372,6 +374,8 @@ struct StorageBucketCachePutPendingDataDeclaration<'scope> { #[webapi(slot = STORAGE_BUCKET_CACHE_PUT_RESPONSE_HEADERS_SLOT)] response_headers_json: String, + #[webapi(slot = STORAGE_BUCKET_CACHE_PUT_RESPONSE_CORS_EXPOSED_HEADERS_SLOT)] + response_cors_exposed_header_names_json: String, } enum StorageBucketCachedResponseMaterialization<'scope> { @@ -1801,6 +1805,10 @@ fn storage_bucket_cache_put_pending_body<'s>( response_status_text: head.status_text, response_headers_json: serde_json::to_string(&head.headers) .unwrap_or_else(|_| "[]".to_owned()), + response_cors_exposed_header_names_json: serde_json::to_string( + &head.cors_exposed_header_names, + ) + .unwrap_or_else(|_| "null".to_owned()), } .bind(scope) .expect("Cache.put pending body data should bind"); @@ -1858,6 +1866,7 @@ fn storage_bucket_cache_put_body_fulfilled_callback<'s>( } }; let response = StorageBucketCachedResponse { + cors_exposed_header_names: pending.response_cors_exposed_header_names, response_type: pending.response_type, url: pending.response_url, redirected: pending.response_redirected, @@ -1897,6 +1906,7 @@ fn storage_bucket_cache_put_body_rejected_callback<'s>( } struct StorageBucketCachePutPendingData<'scope> { + response_cors_exposed_header_names: Option>, resolver: v8::Local<'scope, v8::PromiseResolver>, handle: StorageBucketCacheHandle, request: CacheRequestInfo, @@ -1958,6 +1968,13 @@ fn storage_bucket_cache_put_pending_data<'s>( data, STORAGE_BUCKET_CACHE_PUT_RESPONSE_STATUS_TEXT_SLOT, )?; + let response_cors_exposed_header_names = + serde_json::from_str::>>(&data_private_string( + scope, + data, + STORAGE_BUCKET_CACHE_PUT_RESPONSE_CORS_EXPOSED_HEADERS_SLOT, + )?) + .ok()?; let response_headers_json = data_private_string(scope, data, STORAGE_BUCKET_CACHE_PUT_RESPONSE_HEADERS_SLOT)?; let response_headers = @@ -1967,6 +1984,7 @@ fn storage_bucket_cache_put_pending_data<'s>( indexed_db_storage_key: bucket_storage_key, }; Some(StorageBucketCachePutPendingData { + response_cors_exposed_header_names, resolver, handle: StorageBucketCacheHandle { bucket, @@ -2816,6 +2834,7 @@ fn storage_bucket_cached_response_from_head_body( body: Vec, ) -> StorageBucketCachedResponse { StorageBucketCachedResponse { + cors_exposed_header_names: head.cors_exposed_header_names, response_type: head.response_type, url: head .final_url @@ -2955,13 +2974,38 @@ fn build_storage_bucket_cached_response_object<'s>( ); let status_text = v8_string(scope, &response.status_text)?; init.set_string_property(scope, "statusText", status_text.into()); - let headers = headers_entries_to_init_array(scope, &response.headers); + let public_headers = response + .headers + .iter() + .filter(|(name, _)| { + response.response_type != "cors" + || response + .cors_exposed_header_names + .as_ref() + .is_none_or(|names| { + names + .iter() + .any(|exposed| exposed.eq_ignore_ascii_case(name)) + }) + }) + .cloned() + .collect::>(); + let headers = headers_entries_to_init_array(scope, &public_headers); init.set_string_property(scope, "headers", headers.into()); let global = scope.get_current_context().global(scope); let constructor = global .get(scope, v8str(scope, "Response").into()) .and_then(|value| v8::Local::::try_from(value).ok())?; let response_obj = constructor.new_instance(scope, &[body, init.into_value()])?; + if matches!(response.response_type.as_str(), "basic" | "cors") { + crate::network_host::set_filtered_response_internal_head( + scope, + response_obj, + response.status, + &response.status_text, + &response.headers, + ); + } crate::network_host::set_response_slot_string( scope, response_obj, diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index 5e09dc6d0a..8c8ab43c88 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -144,16 +144,11 @@ pub(in crate::network_host) use self::request_scope::{ subresource_request_scope_for_owner, }; pub(crate) use self::response::{ - FetchResponseRequest, - FetchResponseSecurityViolation, - MaterializedResponseBody, - MaterializedResponseHead, - build_fetch_response_object_for_request_mode, - build_fetch_response_object_from_body_source_for_request_mode, + FetchResponseRequest, FetchResponseSecurityViolation, MaterializedResponseBody, + MaterializedResponseHead, build_fetch_response_object_for_request_mode, build_fetch_response_object_from_body_source_for_request_mode_with_filter, - build_fetch_response_object_from_stream_for_request_mode, build_fetch_response_object_from_stream_for_request_mode_with_filter, - build_fetch_response_object_from_subresource_body_for_request_mode, + build_fetch_response_object_from_subresource_body_for_request_mode_with_filter, build_filtered_cached_response_object, build_navigation_preload_response_object_from_stream_for_request_mode, cors_preflight_request_headers, @@ -174,6 +169,7 @@ pub(crate) use self::response::{ validate_cors_response_chain_for_origin, response_has_null_body, materialize_response_object_internal_head, + set_filtered_response_internal_head, validate_cors_response_for_origin, validate_cross_origin_embedder_and_document_isolation_policy, validate_cross_origin_resource_policy, @@ -322,3 +318,9 @@ use super::{ v8str, }, }; + +#[cfg(test)] +pub(crate) use self::response::{ + build_fetch_response_object_from_stream_for_request_mode, + build_fetch_response_object_from_subresource_body_for_request_mode, +}; diff --git a/moli-renderer-v8/src/network_host/response.rs b/moli-renderer-v8/src/network_host/response.rs index 489d8890d0..076ad05243 100644 --- a/moli-renderer-v8/src/network_host/response.rs +++ b/moli-renderer-v8/src/network_host/response.rs @@ -46,15 +46,21 @@ pub(crate) use self::cors::{ pub(crate) use self::materialize::{ FetchResponseRequest, MaterializedResponseBody, MaterializedResponseHead, build_fetch_response_object_for_request_mode, - build_fetch_response_object_from_body_source_for_request_mode, build_fetch_response_object_from_body_source_for_request_mode_with_filter, - build_fetch_response_object_from_stream_for_request_mode, build_fetch_response_object_from_stream_for_request_mode_with_filter, - build_fetch_response_object_from_subresource_body_for_request_mode, + build_fetch_response_object_from_subresource_body_for_request_mode_with_filter, build_filtered_cached_response_object, build_navigation_preload_response_object_from_stream_for_request_mode, materialize_response_object_body, materialize_response_object_body_with_chunk_callback, - materialize_response_object_internal_head, materialized_body_bytes_from_value, network_response_filter, + materialize_response_object_internal_head, materialized_body_bytes_from_value, + set_filtered_response_internal_head, + network_response_filter, }; #[cfg(test)] pub(crate) use self::materialize::{materialize_response_object, materialize_response_object_head}; + +#[cfg(test)] +pub(crate) use self::materialize::{ + build_fetch_response_object_from_stream_for_request_mode, + build_fetch_response_object_from_subresource_body_for_request_mode, +}; diff --git a/moli-renderer-v8/src/network_host/response/materialize.rs b/moli-renderer-v8/src/network_host/response/materialize.rs index 5dd811116d..7137bbd2af 100644 --- a/moli-renderer-v8/src/network_host/response/materialize.rs +++ b/moli-renderer-v8/src/network_host/response/materialize.rs @@ -17,24 +17,29 @@ pub(crate) struct FetchResponseRequest<'a> { } impl FetchResponseRequest<'_> { - pub(crate) fn filter_response_headers( + pub(crate) fn network_response_filter( self, + document_url: &url::Url, request_origin: &moli_url::WebOrigin, head: &moli_fetch::ResponseHead, credentials_mode: moli_fetch::RequestCredentialsMode, - ) -> Vec<(String, String)> { - // Opaque responses need their internal headers for Cache and respondWith. - // Their public header list is made empty when the Response is built. - if self.mode == RequestMode::NoCors - || self.redirect_mode == RequestRedirectMode::Manual && is_redirect_status(head.status) - { - head.headers.clone() - } else { - filter_cors_exposed_response_headers_for_origin( - request_origin, - head, - credentials_mode, - ) + ) -> crate::types::AsyncSubresourceFetchResponseFilter { + use crate::types::AsyncSubresourceFetchResponseFilter as Filter; + let filter = response_filter(document_url, head, self); + match compute_fetch_response_type(document_url, head, filter) { + "opaque" => Filter::Opaque, + "opaqueredirect" => Filter::OpaqueRedirect, + "cors" => Filter::Cors( + filter_cors_exposed_response_headers_for_origin( + request_origin, + head, + credentials_mode, + ) + .into_iter() + .map(|(name, _)| name) + .collect(), + ), + _ => Filter::Basic, } } } @@ -52,17 +57,11 @@ enum FetchResponseFilter { OpaqueRedirect, } -impl FetchResponseFilter { - fn is_readable(self) -> bool { - matches!(self, Self::None | Self::Basic | Self::Cors) - } -} - -impl From for FetchResponseFilter { - fn from(value: crate::types::AsyncSubresourceFetchResponseFilter) -> Self { +impl From<&crate::types::AsyncSubresourceFetchResponseFilter> for FetchResponseFilter { + fn from(value: &crate::types::AsyncSubresourceFetchResponseFilter) -> Self { match value { crate::types::AsyncSubresourceFetchResponseFilter::Basic => Self::Basic, - crate::types::AsyncSubresourceFetchResponseFilter::Cors => Self::Cors, + crate::types::AsyncSubresourceFetchResponseFilter::Cors(_) => Self::Cors, crate::types::AsyncSubresourceFetchResponseFilter::Opaque => Self::Opaque, crate::types::AsyncSubresourceFetchResponseFilter::OpaqueRedirect => { Self::OpaqueRedirect @@ -111,6 +110,7 @@ fn response_filter( ) -> FetchResponseFilter { let request_origin = request_origin.into(); network_response_filter(&request_origin, head, request.mode, request.redirect_mode) + .as_ref() .map_or(FetchResponseFilter::None, Into::into) } @@ -163,7 +163,11 @@ fn compute_fetch_response_type( } fn filtered_response_status(head: &moli_fetch::ResponseHead, filter: FetchResponseFilter) -> u16 { - if filter.is_readable() { head.status } else { 0 } + if filtered_response_exposes_body(filter) { + head.status + } else { + 0 + } } fn filtered_response_url(head: &moli_fetch::ResponseHead, filter: FetchResponseFilter) -> &str { @@ -171,27 +175,33 @@ fn filtered_response_url(head: &moli_fetch::ResponseHead, filter: FetchResponseF FetchResponseFilter::Opaque => "", // Manual redirects preserve the URL list; only their status, headers, // and body are filtered. No redirect target was fetched. - FetchResponseFilter::None | FetchResponseFilter::Basic | FetchResponseFilter::Cors | FetchResponseFilter::OpaqueRedirect => head.final_url.as_str(), + FetchResponseFilter::None + | FetchResponseFilter::Basic + | FetchResponseFilter::Cors + | FetchResponseFilter::OpaqueRedirect => head.final_url.as_str(), } } fn filtered_response_exposes_body(filter: FetchResponseFilter) -> bool { - filter.is_readable() + !matches!( + filter, + FetchResponseFilter::Opaque | FetchResponseFilter::OpaqueRedirect + ) } fn filtered_response_exposes_redirected(filter: FetchResponseFilter) -> bool { - filter.is_readable() + filtered_response_exposes_body(filter) } fn filtered_response_exposes_headers(filter: FetchResponseFilter) -> bool { - filter.is_readable() + filtered_response_exposes_body(filter) } fn filtered_response_status_text( head: &moli_fetch::ResponseHead, filter: FetchResponseFilter, ) -> &str { - if filter.is_readable() { + if filtered_response_exposes_body(filter) { head.status_text() } else { "" @@ -243,9 +253,17 @@ pub(crate) fn build_fetch_response_object_from_body_source_for_request_mode_with ) -> v8::Local<'s, v8::Object> { let request_origin = request_origin.into(); let filter = filter_override + .as_ref() .map(FetchResponseFilter::from) .unwrap_or_else(|| response_filter(&request_origin, &head, request)); - let obj = build_fetch_response_object_head(scope, &request_origin, &head, filter, None); + let obj = build_fetch_response_object_head( + scope, + &request_origin, + &head, + filter, + None, + filter_override.as_ref(), + ); let body_stream = if response_has_null_body(request.method, head.status) { None } else if filtered_response_exposes_body(filter) { @@ -257,6 +275,7 @@ pub(crate) fn build_fetch_response_object_from_body_source_for_request_mode_with finish_fetch_response_object_with_body_stream(scope, obj, &head, body_stream) } +#[cfg(test)] pub(crate) fn build_fetch_response_object_from_subresource_body_for_request_mode<'s>( scope: &mut v8::PinScope<'s, '_>, request_origin: impl Into, @@ -265,8 +284,37 @@ pub(crate) fn build_fetch_response_object_from_subresource_body_for_request_mode body: crate::protocol_types::SubresourceResponseBody, ) -> v8::Local<'s, v8::Object> { let request_origin = request_origin.into(); - let filter = response_filter(&request_origin, &head, request); - let obj = build_fetch_response_object_head(scope, &request_origin, &head, filter, None); + build_fetch_response_object_from_subresource_body_for_request_mode_with_filter( + scope, + &request_origin, + request, + head, + body, + None, + ) +} + +pub(crate) fn build_fetch_response_object_from_subresource_body_for_request_mode_with_filter<'s>( + scope: &mut v8::PinScope<'s, '_>, + request_origin: impl Into, + request: FetchResponseRequest<'_>, + head: moli_fetch::ResponseHead, + body: crate::protocol_types::SubresourceResponseBody, + filter_override: Option, +) -> v8::Local<'s, v8::Object> { + let request_origin = request_origin.into(); + let filter = filter_override + .as_ref() + .map(FetchResponseFilter::from) + .unwrap_or_else(|| response_filter(&request_origin, &head, request)); + let obj = build_fetch_response_object_head( + scope, + &request_origin, + &head, + filter, + None, + filter_override.as_ref(), + ); let body_stream = if response_has_null_body(request.method, head.status) { None } else if filtered_response_exposes_body(filter) { @@ -278,6 +326,7 @@ pub(crate) fn build_fetch_response_object_from_subresource_body_for_request_mode finish_fetch_response_object_with_body_stream(scope, obj, &head, body_stream) } +#[cfg(test)] pub(crate) fn build_fetch_response_object_from_stream_for_request_mode<'s>( scope: &mut v8::PinScope<'s, '_>, request_origin: impl Into, @@ -345,13 +394,20 @@ fn build_fetch_response_object_from_stream_for_request_mode_with_surface_url<'s> ) -> v8::Local<'s, v8::Object> { let request_origin = request_origin.into(); let filter = filter_override + .as_ref() .map(FetchResponseFilter::from) .unwrap_or_else(|| response_filter(&request_origin, &head, request)); let filtered_surface_url = (filter == FetchResponseFilter::OpaqueRedirect) .then_some(filtered_surface_url) .flatten(); - let obj = - build_fetch_response_object_head(scope, &request_origin, &head, filter, filtered_surface_url); + let obj = build_fetch_response_object_head( + scope, + &request_origin, + &head, + filter, + filtered_surface_url, + filter_override.as_ref(), + ); if response_has_null_body(request.method, head.status) { // Fetch nulls the internal body, including for filtered responses. Do // not register a body source: subsequent transport chunks and terminal @@ -372,6 +428,7 @@ fn build_fetch_response_object_head<'s>( head: &moli_fetch::ResponseHead, filter: FetchResponseFilter, filtered_surface_url: Option<&str>, + filter_override: Option<&crate::types::AsyncSubresourceFetchResponseFilter>, ) -> v8::Local<'s, v8::Object> { let request_origin = request_origin.into(); let status = filtered_response_status(head, filter); @@ -390,22 +447,36 @@ fn build_fetch_response_object_head<'s>( FetchResponseInternalUrlDeclaration::new(head.final_url.to_string()) .initialize(scope, obj) .expect("Fetch Response internal URL declaration should initialize"); - if filter != FetchResponseFilter::None { - set_filtered_response_internal_head( - scope, - obj, - head.status, - head.status_text(), - &head.headers, - ); - } + set_filtered_response_internal_head(scope, obj, head.status, head.status_text(), &head.headers); + let header_entries = if filtered_response_exposes_headers(filter) { + match filter_override { + Some(crate::types::AsyncSubresourceFetchResponseFilter::Cors(names)) => head + .headers + .iter() + .filter(|(name, _)| { + names + .iter() + .any(|exposed| exposed.eq_ignore_ascii_case(name)) + }) + .cloned() + .collect(), + _ => head.headers.clone(), + } + } else { + Vec::new() + }; + let headers = filter_headers_for_guard(&header_entries, HeadersGuard::Response); + let headers_obj = + build_headers_object_with_state(scope, &headers, HeadersGuard::Response, true); + install_headers_object_methods(scope, headers_obj); + set_response_slot_value(scope, obj, RESPONSE_HEADERS_SLOT, headers_obj.into()); mark_response_object(scope, obj); obj } -fn set_filtered_response_internal_head( - scope: &mut v8::PinScope<'_, '_>, - obj: v8::Local<'_, v8::Object>, +pub(crate) fn set_filtered_response_internal_head<'s>( + scope: &mut v8::PinScope<'s, '_>, + obj: v8::Local<'s, v8::Object>, status: u16, status_text: &str, headers: &[(String, String)], @@ -426,6 +497,9 @@ fn set_filtered_response_internal_head( RESPONSE_INTERNAL_HEADERS_SLOT, internal_headers.into(), ); + if let Some(headers) = response_slot_object(scope, obj, RESPONSE_HEADERS_SLOT) { + super::super::headers::mark_headers_immutable(scope, headers); + } } fn finish_fetch_response_object_with_body_stream<'s>( @@ -441,17 +515,6 @@ fn finish_fetch_response_object_with_body_stream<'s>( "opaqueredirect" => FetchResponseFilter::OpaqueRedirect, _ => FetchResponseFilter::None, }; - let header_entries = if filtered_response_exposes_headers(filter) { - head.headers.as_slice() - } else { - &[][..] - }; - let headers = filter_headers_for_guard(header_entries, HeadersGuard::Response); - let headers_obj = - build_headers_object_with_state(scope, &headers, HeadersGuard::Response, true); - install_headers_object_methods(scope, headers_obj); - set_response_slot_value(scope, obj, RESPONSE_HEADERS_SLOT, headers_obj.into()); - let body_value = if !filtered_response_exposes_body(filter) { v8::null(scope).into() } else if let Some(stream) = body_stream { @@ -512,6 +575,7 @@ pub(crate) fn build_filtered_cached_response_object<'s>( #[derive(Debug, Clone)] pub(crate) struct MaterializedResponseObject { + pub(crate) cors_exposed_header_names: Option>, pub(crate) final_url: Option, pub(crate) response_type: String, pub(crate) redirected: bool, @@ -523,6 +587,7 @@ pub(crate) struct MaterializedResponseObject { #[derive(Debug, Clone)] pub(crate) struct MaterializedResponseHead { + pub(crate) cors_exposed_header_names: Option>, pub(crate) final_url: Option, pub(crate) response_type: String, pub(crate) redirected: bool, @@ -534,6 +599,7 @@ pub(crate) struct MaterializedResponseHead { impl MaterializedResponseHead { pub(crate) fn with_body(self, body: Vec) -> MaterializedResponseObject { MaterializedResponseObject { + cors_exposed_header_names: self.cors_exposed_header_names, final_url: self.final_url, response_type: self.response_type, redirected: self.redirected, @@ -635,6 +701,8 @@ pub(crate) fn materialize_response_object_head<'s>( Ok(( MaterializedResponseHead { + cors_exposed_header_names: (response_type == "cors") + .then(|| headers.iter().map(|(name, _)| name.clone()).collect()), final_url, response_type, redirected: response_slot_bool(scope, response, RESPONSE_REDIRECTED_SLOT), @@ -652,9 +720,8 @@ pub(crate) fn materialize_response_object_internal_head<'s>( context: &str, ) -> Result<(MaterializedResponseHead, v8::Local<'s, v8::Object>), String> { let (mut head, response) = materialize_response_object_head(scope, value, context)?; - if matches!(head.response_type.as_str(), "opaque" | "opaqueredirect") - && let Some(internal_status) = - response_slot_number(scope, response, RESPONSE_INTERNAL_STATUS_SLOT) + if let Some(internal_status) = + response_slot_number(scope, response, RESPONSE_INTERNAL_STATUS_SLOT) { head.status = internal_status as u16; head.status_text = diff --git a/moli-renderer-v8/src/planning.rs b/moli-renderer-v8/src/planning.rs index 2621ee0928..d0c156fc32 100644 --- a/moli-renderer-v8/src/planning.rs +++ b/moli-renderer-v8/src/planning.rs @@ -533,7 +533,7 @@ pub(crate) fn spawn_service_worker_aware_external_script_source_load( ) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] enum ClassicScriptResponseProvenance { Network, ServiceWorker { diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch.rs b/moli-renderer-v8/src/script_vm/subresource_fetch.rs index c8f31fa5f3..7ee9c9f2a2 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch.rs @@ -3745,11 +3745,12 @@ impl ScriptVm { mode: pending.request_mode, redirect_mode, }; - head.headers = response_request.filter_response_headers( + let response_filter = response_filter.or_else(|| Some(response_request.network_response_filter( + &pending.info.document_url, &pending.request_origin(), &head, pending.credentials_mode, - ); + ))); if let Some(status_text) = response_status_text { head.status_text = Some(status_text); } @@ -4541,6 +4542,7 @@ impl ScriptVm { .borrow_mut() .record_streaming_subresource_fetch(StreamingSubresourceFetchState { response_filter: started.response_filter, + skip_fetch_security_validation: started.skip_fetch_security_validation, pending, request_url: started.request_url, request_method: started.request_method, @@ -4684,15 +4686,12 @@ impl ScriptVm { None } .or_else(|| { - if started.skip_fetch_security_validation { - return None; - } - matches!( + (!started.skip_fetch_security_validation && matches!( pending.info.resource_type, SubresourceResourceType::EventSource | SubresourceResourceType::Fetch | SubresourceResourceType::Xhr - ) + )) .then(|| { crate::network_host::validate_fetch_response_headers_for_origin( &pending.info.document_url, @@ -4926,6 +4925,7 @@ impl ScriptVm { .borrow_mut() .record_streaming_subresource_fetch(StreamingSubresourceFetchState { response_filter: started.response_filter, + skip_fetch_security_validation: started.skip_fetch_security_validation, pending, request_url: started.request_url.clone(), request_method: started.request_method.clone(), @@ -4977,18 +4977,19 @@ impl ScriptVm { mode: pending.request_mode, redirect_mode: fetch.redirect_mode(), }; - observable_head.headers = response_request.filter_response_headers( + let response_filter = started.response_filter.clone().or_else(|| Some(response_request.network_response_filter( + &pending.info.document_url, &pending.request_origin(), &observable_head, pending.credentials_mode, - ); + ))); let response_obj = crate::network_host::build_fetch_response_object_from_stream_for_request_mode_with_filter( scope, &pending.request_origin, response_request, observable_head, started.body_source_id, - started.response_filter, + response_filter, ); resolver.resolve(scope, response_obj.into()); } @@ -5088,6 +5089,7 @@ impl ScriptVm { self._context_host.borrow_mut().record_streaming_subresource_fetch( StreamingSubresourceFetchState { response_filter: started.response_filter, + skip_fetch_security_validation: started.skip_fetch_security_validation, pending, request_url: started.request_url.clone(), request_method: started.request_method.clone(), diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs index 1fadee70db..469055f147 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs @@ -14638,7 +14638,7 @@ async fn navigator_service_worker_fetch_event_request_preserves_worker_fetch_pol "referrerPolicy=" + event.request.referrerPolicy, "integrity=" + event.request.integrity, "keepalive=" + event.request.keepalive - ].join("|"))); + ].join("|"), {headers: {"x-response": "from-worker"}})); }); "#, ), @@ -14648,7 +14648,9 @@ async fn navigator_service_worker_fetch_event_request_preserves_worker_fetch_pol r#" self.onmessage = async () => { try { - const response = await fetch("api/worker-metadata.txt", { + const target = new URL("api/worker-metadata.txt", location.href); + target.hostname = target.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1"; + const response = await fetch(target, { cache: "reload", referrer: "./worker-referrer.html", referrerPolicy: "origin", @@ -14656,7 +14658,7 @@ async fn navigator_service_worker_fetch_event_request_preserves_worker_fetch_pol keepalive: true, priority: "high" }); - postMessage(response.status + "|" + await response.text()); + postMessage([response.status, response.type, response.headers.get("x-response"), await response.text()].join("|")); } catch (error) { postMessage("error:" + String(error && error.message)); } @@ -14705,7 +14707,7 @@ async fn navigator_service_worker_fetch_event_request_preserves_worker_fetch_pol &loader, "String(globalThis.__serviceWorkerWorkerFetchRequestPolicyMetadataProbe)", &format!( - "200|cache=reload|referrer={base_url}/app/worker-referrer.html|referrerPolicy=origin|integrity=sha256-test|keepalive=true" + "200|basic|from-worker|cache=reload|referrer={base_url}/app/worker-referrer.html|referrerPolicy=origin|integrity=sha256-test|keepalive=true" ), ) .await; diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/null_body.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/null_body.rs index 246c706731..3394184926 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/null_body.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/null_body.rs @@ -185,6 +185,8 @@ fn xhr_null_body_statuses_discard_buffered_and_streamed_bytes() { let body_source_id = crate::network_host::new_network_body_source_id(); vm.start_streaming_async_subresource_fetch( crate::types::AsyncSubresourceStreamingStarted { + response_filter: None, + skip_fetch_security_validation: false, internal_id: request.internal_id, request_url: request.url.clone(), request_method: method.to_owned(), @@ -321,6 +323,8 @@ fn xhr_null_body_late_completion_preserves_a_replacement_requests_progress() { let body_source_id = crate::network_host::new_network_body_source_id(); vm.start_streaming_async_subresource_fetch( crate::types::AsyncSubresourceStreamingStarted { + response_filter: None, + skip_fetch_security_validation: false, internal_id: request.internal_id, request_url: request.url.clone(), request_method: method.to_owned(), diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/opaque_response.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/opaque_response.rs index a7b25657a9..cbb119f2d7 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/opaque_response.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/opaque_response.rs @@ -1,6 +1,174 @@ use super::*; use crate::util::v8str; +#[test] +fn window_service_worker_response_filter_survives_streaming_clone_and_cache() { + use crate::types::AsyncSubresourceFetchResponseFilter as Filter; + for streaming in [false, true] { + for mode in ["cors", "no-cors"] { + for cors in [false, true] { + let mut vm = new_storage_test_vm("https://response-client.test/"); + vm.set_fetch_subresource_interception( + true, + Some(crate::types::SubresourceResourceType::Fetch), + ); + let response_type = if cors { "cors" } else { "basic" }; + vm.eval(&format!(r#" + globalThis.result = 'pending'; + fetch('https://response-remote.test/body', {{mode: '{mode}'}}).then(async response => {{ + globalThis.original = response; + globalThis.cloned = response.clone(); + const bucket = await navigator.storageBuckets.open('response-filter'); + const cache = await bucket.caches.open('responses'); + await cache.put('/key', cloned.clone()); + globalThis.cached = await cache.match('/key'); + await navigator.storageBuckets.delete('response-filter'); + for (const entry of [original, cloned, cached]) {{ + if (entry.type !== '{response_type}' || entry.status !== 200 || entry.body === null || + entry.headers.get('x-visible') !== 'visible' || entry.headers.has('set-cookie') || + entry.headers.has('x-hidden') === {cors}) throw new Error('public response'); + let error; + try {{ entry.headers.set('x-author', 'changed'); }} catch (value) {{ error = value; }} + if (!(error instanceof TypeError)) throw new Error('immutable headers'); + if (await entry.clone().text() !== 'hello') throw new Error('body'); + }} + result = 'ok'; + }}).catch(error => result = String(error.stack || error)); + "#)).unwrap(); + let requests = vm.take_pending_subresource_fetch_infos(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + let headers = vec![ + ("Content-Type".to_owned(), "text/plain".to_owned()), + ("Content-Length".to_owned(), "5".to_owned()), + ("X-Visible".to_owned(), "visible".to_owned()), + ("X-Hidden".to_owned(), "secret".to_owned()), + ( + "Cross-Origin-Resource-Policy".to_owned(), + "cross-origin".to_owned(), + ), + ("Set-Cookie".to_owned(), "hidden=secret".to_owned()), + ( + "Vary".to_owned(), + if cors { "*" } else { "Accept" }.to_owned(), + ), + ]; + let head = moli_fetch::ResponseHead { + final_url: request.url.clone(), + status: 200, + status_text: Some("OK".to_owned()), + headers: headers.clone(), + request_cookie_report: None, + cookie_set_reports: Vec::new(), + redirected: false, + redirect_chain: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }; + let response_filter = Some(if cors { + Filter::Cors(vec!["content-type".to_owned(), "x-visible".to_owned()]) + } else { + Filter::Basic + }); + if streaming { + let body_source_id = crate::network_host::new_network_body_source_id(); + vm.start_streaming_async_subresource_fetch( + crate::types::AsyncSubresourceStreamingStarted { + response_filter, + skip_fetch_security_validation: true, + internal_id: request.internal_id, + request_url: request.url.clone(), + request_method: "GET".to_owned(), + request_headers: Vec::new(), + request_body: None, + body_source_id, + head, + network_request_headers: None, + }, + ) + .unwrap(); + vm.append_streaming_async_subresource_fetch_chunk( + body_source_id, + b"hello".to_vec(), + ); + vm.finish_streaming_async_subresource_fetch( + request.internal_id, + body_source_id, + Ok(()), + ) + .unwrap(); + } else { + vm.complete_async_subresource_fetch( + crate::types::AsyncSubresourceFetchCompletion { + response_filter, + skip_fetch_security_validation: true, + internal_id: request.internal_id, + request_url: request.url.clone(), + request_method: "GET".to_owned(), + request_headers: Vec::new(), + request_body: None, + response_status_text: None, + network_error_text: None, + result: Ok( + crate::protocol_types::NavigationResponse::from_head_and_body( + head, + "hello".to_owned(), + b"hello".to_vec(), + ), + ), + }, + ) + .unwrap(); + } + vm.exec("0", None).unwrap(); + assert_eq!( + vm.eval("result").unwrap(), + "ok", + "{response_type}/{mode}/streaming={streaming}" + ); + let context_ptr: *const v8::Global = &vm.page_default_context; + vm.renderer_document_isolate + .with_entered_renderer_document_isolate(move |isolate| { + let scope = std::pin::pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let context = unsafe { v8::Local::new(scope, &*context_ptr) }; + let scope = &mut v8::ContextScope::new(scope, context); + for name in ["original", "cloned", "cached"] { + let value = context + .global(scope) + .get(scope, v8str(scope, name).into()) + .unwrap(); + let (head, _) = + crate::network_host::materialize_response_object_internal_head( + scope, value, "test", + ) + .unwrap(); + assert_eq!(head.response_type, response_type); + for (name, value) in &headers { + assert!( + head.headers + .iter() + .any(|(key, entry)| key.eq_ignore_ascii_case(name) + && entry == value), + "{name}: {:?}", + head.headers + ); + } + if cors { + assert_eq!( + head.cors_exposed_header_names, + Some(vec!["content-type".to_owned(), "x-visible".to_owned()]) + ); + } + } + Ok(()) + }) + .unwrap(); + } + } + } +} + #[test] fn window_filtered_fetch_preserves_internal_head_through_clone_and_cache() { for streaming in [false, true] { @@ -68,6 +236,8 @@ fn window_filtered_fetch_preserves_internal_head_through_clone_and_cache() { let body_source_id = crate::network_host::new_network_body_source_id(); vm.start_streaming_async_subresource_fetch( crate::types::AsyncSubresourceStreamingStarted { + response_filter: None, + skip_fetch_security_validation: false, internal_id: request.internal_id, request_url: request.url.clone(), request_method: "GET".to_owned(), diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/redirect_filter.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/redirect_filter.rs index 1a226ce5fe..2dffa579fa 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/redirect_filter.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/redirect_filter.rs @@ -43,7 +43,7 @@ fn redirect_filter_completion_keeps_status_text_and_explicit_filters() { request_body: None, response_status_text: Some("Override Text".to_owned()), skip_fetch_security_validation: true, - response_filter: filter, + response_filter: filter.clone(), network_error_text: None, result: Ok( crate::protocol_types::NavigationResponse::from_head_and_body( @@ -66,7 +66,9 @@ fn redirect_filter_completion_keeps_status_text_and_explicit_filters() { }, ) .unwrap(); - let expected_type = match filter { + let expected_type = match &filter { + Some(crate::types::AsyncSubresourceFetchResponseFilter::Basic) => "basic", + Some(crate::types::AsyncSubresourceFetchResponseFilter::Cors(_)) => "cors", Some(Opaque) => "opaque", Some(OpaqueRedirect) => "opaqueredirect", None if redirect == "manual" && status == 302 => "opaqueredirect", diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs index 43e3d09fcf..3db3fa9a57 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs @@ -1434,6 +1434,8 @@ fn xml_http_request_document_response_requires_an_eligible_mime_and_well_formed_ let body_source_id = crate::network_host::new_network_body_source_id(); vm.start_streaming_async_subresource_fetch( crate::types::AsyncSubresourceStreamingStarted { + response_filter: None, + skip_fetch_security_validation: false, internal_id: request.internal_id, request_url: request.url.clone(), request_method: "GET".to_owned(), @@ -1562,6 +1564,8 @@ fn xhr_streamed_response_documents_keep_distinct_source_modification_times() { let body_source_id = crate::network_host::new_network_body_source_id(); vm.start_streaming_async_subresource_fetch( crate::types::AsyncSubresourceStreamingStarted { + response_filter: None, + skip_fetch_security_validation: false, internal_id: request.internal_id, request_url: request.url.clone(), request_method: "GET".to_owned(), @@ -1699,6 +1703,8 @@ fn xhr_response_decoding_uses_headers_received_overrides_for_buffered_and_stream let body_source_id = crate::network_host::new_network_body_source_id(); vm.start_streaming_async_subresource_fetch( crate::types::AsyncSubresourceStreamingStarted { + response_filter: None, + skip_fetch_security_validation: false, internal_id: request.internal_id, request_url: request.url.clone(), request_method: "GET".to_owned(), @@ -2992,6 +2998,7 @@ async fn streaming_subresource_finish_preserves_response_head_cache_state() { .borrow_mut() .record_streaming_subresource_fetch(super::StreamingSubresourceFetchState { response_filter: None, + skip_fetch_security_validation: false, pending: super::PendingSubresourceFetchState { request_origin: moli_url::WebOrigin::from_url(&document_url), info: crate::types::PendingSubresourceFetchInfo { @@ -3280,6 +3287,7 @@ async fn streaming_fetch_body_error_records_response_started_then_body_failed() .borrow_mut() .record_streaming_subresource_fetch(super::StreamingSubresourceFetchState { response_filter: None, + skip_fetch_security_validation: false, pending: super::PendingSubresourceFetchState { request_origin: moli_url::WebOrigin::from_url( &(Url::parse("https://streaming-fetch-body-error.test/") @@ -3447,6 +3455,7 @@ fn install_streaming_fetch_response_fixture( .borrow_mut() .record_streaming_subresource_fetch(super::StreamingSubresourceFetchState { response_filter: None, + skip_fetch_security_validation: false, pending: super::PendingSubresourceFetchState { request_origin: moli_url::WebOrigin::from_url(&document_url), info: crate::types::PendingSubresourceFetchInfo { @@ -3551,6 +3560,7 @@ async fn streaming_fetch_body_cancel_aborts_streaming_subresource() { .borrow_mut() .record_streaming_subresource_fetch(super::StreamingSubresourceFetchState { response_filter: None, + skip_fetch_security_validation: false, pending: super::PendingSubresourceFetchState { request_origin: moli_url::WebOrigin::from_url( &(Url::parse("https://streaming-fetch-body-cancel.test/") @@ -4181,6 +4191,7 @@ async fn streaming_xhr_materialization_failure_errors_body_source_before_close() .borrow_mut() .record_streaming_subresource_fetch(super::StreamingSubresourceFetchState { response_filter: None, + skip_fetch_security_validation: false, pending: super::PendingSubresourceFetchState { request_origin: moli_url::WebOrigin::from_url( &(Url::parse("https://xhr-streaming-materialize-error.test/") diff --git a/moli-renderer-v8/src/script_vm/tests/indexed_db.rs b/moli-renderer-v8/src/script_vm/tests/indexed_db.rs index 64e8985d08..ebfc8209b3 100644 --- a/moli-renderer-v8/src/script_vm/tests/indexed_db.rs +++ b/moli-renderer-v8/src/script_vm/tests/indexed_db.rs @@ -3024,6 +3024,7 @@ fn default_bucket_quota_is_shared_by_cache_indexed_db_and_opfs() { "fixture", "/reserved", moli_storage_service::StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: format!("{page_url}reserved"), redirected: false, diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 23ee52f79e..e7411e3c9e 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -354,6 +354,7 @@ fn register_pending_window_fetch_for_test( host.record_streaming_subresource_fetch( crate::types::StreamingSubresourceFetchState { response_filter: None, + skip_fetch_security_validation: false, pending, request_url: url.clone(), request_method: "GET".to_owned(), diff --git a/moli-renderer-v8/src/script_vm/tests/webidl_fetch.rs b/moli-renderer-v8/src/script_vm/tests/webidl_fetch.rs index 409842dff2..3506790377 100644 --- a/moli-renderer-v8/src/script_vm/tests/webidl_fetch.rs +++ b/moli-renderer-v8/src/script_vm/tests/webidl_fetch.rs @@ -9115,6 +9115,8 @@ fn opaque_window_fetch_keeps_blocked_bytes_out_of_internal_clone_consumers() { let id = crate::network_host::new_network_body_source_id(); vm.start_streaming_async_subresource_fetch( crate::types::AsyncSubresourceStreamingStarted { + response_filter: None, + skip_fetch_security_validation: false, internal_id: pending.internal_id, request_url: pending.url.clone(), request_method: "GET".to_owned(), diff --git a/moli-renderer-v8/src/service_worker_runtime/events.rs b/moli-renderer-v8/src/service_worker_runtime/events.rs index 3a8706ddd7..2575b30c9c 100644 --- a/moli-renderer-v8/src/service_worker_runtime/events.rs +++ b/moli-renderer-v8/src/service_worker_runtime/events.rs @@ -628,6 +628,7 @@ pub(crate) struct ServiceWorkerFetchEvent { #[derive(Clone, Debug)] pub(crate) struct ServiceWorkerFetchResponse { + pub(crate) cors_exposed_header_names: Option>, pub(crate) final_url: Option, pub(crate) response_type: String, pub(crate) redirected: bool, @@ -694,6 +695,7 @@ pub(crate) struct ServiceWorkerNavigationPreloadFailure { #[derive(Clone, Debug)] pub(crate) struct MaterializedServiceWorkerFetchResponseHead { + pub(crate) cors_exposed_header_names: Option>, pub(crate) final_url: Option, pub(crate) response_type: String, pub(crate) redirected: bool, diff --git a/moli-renderer-v8/src/service_worker_runtime/service.rs b/moli-renderer-v8/src/service_worker_runtime/service.rs index 0200c51624..64e2293810 100644 --- a/moli-renderer-v8/src/service_worker_runtime/service.rs +++ b/moli-renderer-v8/src/service_worker_runtime/service.rs @@ -8713,6 +8713,7 @@ self.addEventListener("message", event => { event_id, owner: test_run_owner(version_id, &run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, status: 200, status_text: "OK".to_owned(), headers: vec![("content-type".to_owned(), "text/plain".to_owned())], @@ -9014,6 +9015,7 @@ self.addEventListener("message", event => { event_id, owner: test_run_owner(version_id, &run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, status: 200, status_text: "OK".to_owned(), headers: vec![("content-type".to_owned(), "text/plain".to_owned())], diff --git a/moli-renderer-v8/src/service_worker_runtime/service/event_dispatch.rs b/moli-renderer-v8/src/service_worker_runtime/service/event_dispatch.rs index 94a03c6a7a..6392f35448 100644 --- a/moli-renderer-v8/src/service_worker_runtime/service/event_dispatch.rs +++ b/moli-renderer-v8/src/service_worker_runtime/service/event_dispatch.rs @@ -61,6 +61,7 @@ fn navigation_preload_response_head( head: moli_fetch::ResponseHead, ) -> MaterializedServiceWorkerFetchResponseHead { MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, status_text: head.status_text().to_owned(), final_url: Some(head.final_url), response_type: "default".to_owned(), diff --git a/moli-renderer-v8/src/service_worker_runtime/service/fetch_settlement.rs b/moli-renderer-v8/src/service_worker_runtime/service/fetch_settlement.rs index a548223ac2..1f5a7023fb 100644 --- a/moli-renderer-v8/src/service_worker_runtime/service/fetch_settlement.rs +++ b/moli-renderer-v8/src/service_worker_runtime/service/fetch_settlement.rs @@ -123,10 +123,16 @@ fn service_worker_fetch_can_forward_stream( job: &ServiceWorkerFetchJob, response_head: &MaterializedServiceWorkerFetchResponseHead, ) -> Result { - let final_url = response_head.final_url.as_ref().unwrap_or(&job.request.url); + if let Some(message) = service_worker_fetch_response_head_rejection( + job, + &response_head.response_type, + response_head.redirected, + ) { + return Err(message); + } validate_service_worker_fetch_response_head_security_policy( job, - final_url, + response_head.final_url.as_ref(), &response_head.headers, )?; Ok(job.direct_completion_tx.is_none() @@ -140,8 +146,10 @@ fn service_worker_fetch_can_forward_stream( | crate::types::SubresourceResourceType::Video ) && !is_redirect_status(response_head.status) - && matches!(response_head.response_type.as_str(), "default" | "basic") - && !service_worker_fetch_response_requires_body_security_policy(job)) + && matches!( + response_head.response_type.as_str(), + "default" | "basic" | "cors" + )) } fn apply_service_worker_synthetic_redirect( @@ -383,10 +391,13 @@ impl ServiceWorkerRuntimeService { job.completion_tx.clone(), AsyncSubresourceFetchEvent::StreamingStarted(Box::new( AsyncSubresourceStreamingStarted { - skip_fetch_security_validation: true, - response_filter: service_worker_response_type_filter( + response_filter: service_worker_fetch_response_head_filter( &started.response_head.response_type, + started.response_head.cors_exposed_header_names.as_deref(), + started.response_head.status, + job.request.redirect_mode, ), + skip_fetch_security_validation: true, internal_id: job.internal_id, request_url: job.request.url.clone(), request_method: job.request.method.clone(), @@ -595,13 +606,17 @@ impl ServiceWorkerRuntimeService { .final_url .clone() .unwrap_or_else(|| job.request.url.clone()); - if let Err(message) = - validate_service_worker_fetch_response_security_policy(&job, &response, &final_url) - { + // The inner network fetch already applies ORB. Synthesized bodies belong + // to the service worker; only the receiving client's CORP/COEP is checked here. + if let Err(message) = validate_service_worker_fetch_response_head_security_policy( + &job, + response.final_url.as_ref(), + &response.headers, + ) { self.complete_fetch_with_failure(job, message); return; } - let response_filter = service_worker_fetch_response_filter(&response, job.redirect_mode); + let response_filter = service_worker_fetch_response_filter(&response, job.request.redirect_mode); let navigation_response = crate::protocol_types::NavigationResponse::from_head_and_body( moli_fetch::ResponseHead { status_text: Some(response.status_text.clone()), @@ -759,57 +774,16 @@ fn service_worker_fetch_is_navigation_request(job: &ServiceWorkerFetchJob) -> bo ) } -fn validate_service_worker_fetch_response_security_policy( - job: &ServiceWorkerFetchJob, - response: &ServiceWorkerFetchResponse, - final_url: &Url, -) -> Result<(), String> { - validate_service_worker_fetch_response_body_security_policy(job, response, final_url)?; - validate_service_worker_fetch_response_head_security_policy(job, final_url, &response.headers) -} - -fn service_worker_fetch_response_requires_body_security_policy( - job: &ServiceWorkerFetchJob, -) -> bool { - job.request.request_mode == moli_fetch::RequestMode::NoCors - && matches!( - job.network_context.resource_type, - crate::types::SubresourceResourceType::Fetch - | crate::types::SubresourceResourceType::Xhr - ) -} - -fn validate_service_worker_fetch_response_body_security_policy( - job: &ServiceWorkerFetchJob, - response: &ServiceWorkerFetchResponse, - final_url: &Url, -) -> Result<(), String> { - if job.request.request_mode != moli_fetch::RequestMode::NoCors - || !matches!( - job.network_context.resource_type, - crate::types::SubresourceResourceType::Fetch - | crate::types::SubresourceResourceType::Xhr - ) - { - return Ok(()); - } - - validate_service_worker_fetch_response_head_security_policy(job, final_url, &response.headers)?; - crate::network_host::validate_opaque_response_blocking_with_body( - job.request - .browser_origin() - .map_err(|error| error.to_string())?, - final_url, - &response.headers, - &response.body, - ) -} - fn validate_service_worker_fetch_response_head_security_policy( job: &ServiceWorkerFetchJob, - final_url: &Url, + final_url: Option<&Url>, headers: &[(String, String)], ) -> Result<(), String> { + // Synthesized responses have an empty URL list and are same-origin to the + // service worker's client. A network response retains its actual origin. + let Some(final_url) = final_url else { + return Ok(()); + }; if job.request.request_mode != moli_fetch::RequestMode::NoCors || matches!( job.network_context.resource_type, @@ -841,7 +815,15 @@ fn service_worker_fetch_response_rejection( job: &ServiceWorkerFetchJob, response: &ServiceWorkerFetchResponse, ) -> Option { - match response.response_type.as_str() { + service_worker_fetch_response_head_rejection(job, &response.response_type, response.redirected) +} + +fn service_worker_fetch_response_head_rejection( + job: &ServiceWorkerFetchJob, + response_type: &str, + redirected: bool, +) -> Option { + match response_type { "error" => { return Some("FetchEvent.respondWith rejected an error Response".to_owned()); } @@ -871,7 +853,7 @@ fn service_worker_fetch_response_rejection( } _ => {} } - if response.redirected && job.request.redirect_mode != moli_fetch::RequestRedirectMode::Follow { + if redirected && job.request.redirect_mode != moli_fetch::RequestRedirectMode::Follow { return Some( "FetchEvent.respondWith rejected a redirected Response for a request whose redirect mode is not follow" .to_owned(), @@ -884,26 +866,33 @@ fn service_worker_fetch_response_filter( response: &ServiceWorkerFetchResponse, redirect_mode: moli_fetch::RequestRedirectMode, ) -> Option { - if redirect_mode == moli_fetch::RequestRedirectMode::Manual - && is_redirect_status(response.status) - && !matches!(response.response_type.as_str(), "opaque" | "opaqueredirect") - { - Some(AsyncSubresourceFetchResponseFilter::OpaqueRedirect) - } else { - service_worker_response_type_filter(&response.response_type) - } + service_worker_fetch_response_head_filter( + &response.response_type, + response.cors_exposed_header_names.as_deref(), + response.status, + redirect_mode, + ) } -fn service_worker_response_type_filter( +fn service_worker_fetch_response_head_filter( response_type: &str, + cors_exposed_header_names: Option<&[String]>, + status: u16, + redirect_mode: moli_fetch::RequestRedirectMode, ) -> Option { - match response_type { - "basic" => Some(AsyncSubresourceFetchResponseFilter::Basic), - "cors" => Some(AsyncSubresourceFetchResponseFilter::Cors), - "opaque" => Some(AsyncSubresourceFetchResponseFilter::Opaque), - "opaqueredirect" => Some(AsyncSubresourceFetchResponseFilter::OpaqueRedirect), - _ => None, - } + Some(match response_type { + "opaque" => AsyncSubresourceFetchResponseFilter::Opaque, + "opaqueredirect" => AsyncSubresourceFetchResponseFilter::OpaqueRedirect, + _ if redirect_mode == moli_fetch::RequestRedirectMode::Manual + && is_redirect_status(status) => + { + AsyncSubresourceFetchResponseFilter::OpaqueRedirect + } + "cors" => AsyncSubresourceFetchResponseFilter::Cors( + cors_exposed_header_names.unwrap_or_default().to_vec(), + ), + _ => AsyncSubresourceFetchResponseFilter::Basic, + }) } #[cfg(test)] @@ -1263,6 +1252,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(final_url.clone()), response_type: "default".to_owned(), redirected: false, @@ -1279,7 +1269,10 @@ mod tests { assert_eq!(completion.request_method, "GET"); assert_eq!(completion.response_status_text.as_deref(), Some("Accepted")); assert!(completion.skip_fetch_security_validation); - assert_eq!(completion.response_filter, None); + assert_eq!( + completion.response_filter, + Some(AsyncSubresourceFetchResponseFilter::Basic) + ); let response = completion .result .expect("service worker response should resolve"); @@ -1361,6 +1354,7 @@ mod tests { ), body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some(final_url.clone()), response_type: "default".to_owned(), redirected: false, @@ -1399,6 +1393,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(final_url), response_type: "default".to_owned(), redirected: false, @@ -1446,6 +1441,7 @@ mod tests { ), body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some(final_url), response_type: "default".to_owned(), redirected: false, @@ -1523,6 +1519,7 @@ mod tests { version_id, run, ), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), redirected: false, @@ -1547,8 +1544,11 @@ mod tests { } else { assert_eq!( completion.response_filter, - (mode == Manual) - .then_some(AsyncSubresourceFetchResponseFilter::OpaqueRedirect) + Some(if mode == Manual { + AsyncSubresourceFetchResponseFilter::OpaqueRedirect + } else { + AsyncSubresourceFetchResponseFilter::Basic + }) ); let response = completion.result.unwrap(); assert_eq!(response.status, status); @@ -1584,6 +1584,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "opaque".to_owned(), redirected: false, @@ -1629,6 +1630,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "opaqueredirect".to_owned(), redirected: false, @@ -1673,6 +1675,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(final_url.clone()), response_type: "default".to_owned(), redirected: true, @@ -1716,6 +1719,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://example.test/app/manual-final.txt")), response_type: "default".to_owned(), redirected: true, @@ -1764,6 +1768,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), redirected: false, @@ -1887,6 +1892,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), redirected: false, @@ -1948,6 +1954,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), redirected: false, @@ -2055,6 +2062,7 @@ mod tests { .without_inferred_referrer() .with_fetch_priority_hint(Some(moli_fetch::FetchPriorityHint::High)); let response = ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(request_url.clone()), response_type: "default".to_owned(), redirected: false, @@ -2129,6 +2137,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), redirected: false, @@ -2176,6 +2185,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(response_url.clone()), response_type: "default".to_owned(), redirected: false, @@ -2243,6 +2253,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "error".to_owned(), redirected: false, @@ -2290,6 +2301,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cross-origin.test/data.txt")), response_type: "cors".to_owned(), redirected: false, @@ -2336,6 +2348,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "opaque".to_owned(), redirected: false, @@ -2398,6 +2411,7 @@ mod tests { run.clone(), ), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "opaque".to_owned(), redirected: false, @@ -2445,6 +2459,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/image.png")), response_type: "default".to_owned(), redirected: false, @@ -2498,6 +2513,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/image.png")), response_type: "default".to_owned(), redirected: false, @@ -2561,6 +2577,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -2620,6 +2637,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -2678,6 +2696,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -2736,6 +2755,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -2795,6 +2815,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -2848,6 +2869,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -2906,6 +2928,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -2963,6 +2986,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -3014,6 +3038,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -3073,6 +3098,7 @@ mod tests { owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -3141,6 +3167,7 @@ mod tests { owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -3206,6 +3233,7 @@ mod tests { owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), redirected: false, @@ -3235,7 +3263,7 @@ mod tests { } #[test] - fn response_completion_rejects_no_cors_service_worker_response_blocked_by_orb() { + fn response_completion_keeps_synthesized_json_response_visible_to_no_cors_fetch() { let service = new_service_worker_runtime_service(); let event_id = ServiceWorkerEventId(51); let version_id = ServiceWorkerVersionId(1); @@ -3257,27 +3285,30 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { - final_url: Some(url("https://cdn.example.test/app/data.json")), + cors_exposed_header_names: None, + final_url: None, response_type: "default".to_owned(), redirected: false, status: 200, status_text: "OK".to_owned(), headers: vec![("content-type".to_owned(), "application/json".to_owned())], - body: br#"{"secret":true}"#.to_vec(), + body: br#"{"visible":true}"#.to_vec(), }), }); let completion = pop_async_subresource_completion(&mut completion_queue); assert_eq!(completion.internal_id, 331); - assert_eq!(completion.response_status_text, None); - assert_eq!(completion.response_filter, None); - assert!(!completion.skip_fetch_security_validation); - assert!( - completion - .result - .expect_err("ORB should reject") - .contains("OpaqueResponseBlocking") + assert_eq!(completion.response_status_text.as_deref(), Some("OK")); + assert_eq!( + completion.response_filter, + Some(AsyncSubresourceFetchResponseFilter::Basic) ); + assert!(completion.skip_fetch_security_validation); + let response = completion + .result + .expect("A synthesized response is readable by the service worker's client"); + assert_eq!(response.status, 200); + assert_eq!(response.body_text(), r#"{"visible":true}"#); } #[test] @@ -3303,6 +3334,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/data.json")), response_type: "default".to_owned(), redirected: false, @@ -3316,7 +3348,10 @@ mod tests { let completion = pop_async_subresource_completion(&mut completion_queue); assert_eq!(completion.internal_id, 332); assert!(completion.skip_fetch_security_validation); - assert_eq!(completion.response_filter, None); + assert_eq!( + completion.response_filter, + Some(AsyncSubresourceFetchResponseFilter::Basic) + ); let response = completion .result .expect("Service Worker response should not need ACAO"); @@ -3347,6 +3382,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "opaqueredirect".to_owned(), redirected: false, diff --git a/moli-renderer-v8/src/stylesheet_blocking.rs b/moli-renderer-v8/src/stylesheet_blocking.rs index 5744d75796..3516b0d1e0 100644 --- a/moli-renderer-v8/src/stylesheet_blocking.rs +++ b/moli-renderer-v8/src/stylesheet_blocking.rs @@ -255,7 +255,7 @@ async fn fetch_stylesheet_readiness_with_request( } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] enum StylesheetResponseProvenance { Network, ServiceWorker { @@ -265,7 +265,7 @@ enum StylesheetResponseProvenance { impl StylesheetResponseProvenance { fn is_cors_same_origin( - self, + &self, document_url: &Url, request_url: &Url, response: &crate::protocol_types::NavigationResponse, @@ -307,7 +307,7 @@ fn stylesheet_terminal_from_response( let (request_mode, credentials_mode) = options.request_mode_and_credentials(); let head = response.head(); let cors_usability = - (request_mode == moli_fetch::RequestMode::Cors).then(|| match response_provenance { + (request_mode == moli_fetch::RequestMode::Cors).then(|| match &response_provenance { StylesheetResponseProvenance::ServiceWorker { filter: Some( diff --git a/moli-renderer-v8/src/stylesheet_runtime/connected.rs b/moli-renderer-v8/src/stylesheet_runtime/connected.rs index 6db19dbfba..9fb7de00d3 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/connected.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/connected.rs @@ -2154,7 +2154,7 @@ async fn fetch_connected_link_readiness_with_service_worker( Ok(Some(response)) => { let response_filter = response.response_filter; let origin_clean = - connected_link_origin_clean_from_service_worker_filter(response_filter); + connected_link_origin_clean_from_service_worker_filter(response_filter.clone()); let response = *response.response; let load_event_successful = connected_link_load_event_successful(&response, response_filter); diff --git a/moli-renderer-v8/src/types.rs b/moli-renderer-v8/src/types.rs index 713b939cd6..565c212c9e 100644 --- a/moli-renderer-v8/src/types.rs +++ b/moli-renderer-v8/src/types.rs @@ -650,17 +650,17 @@ pub(super) struct AsyncSubresourceFetchCompletion { pub(super) result: std::result::Result, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(super) enum AsyncSubresourceFetchResponseFilter { Basic, - Cors, + Cors(Vec), Opaque, OpaqueRedirect, } impl AsyncSubresourceFetchResponseFilter { - pub(super) fn is_readable(self) -> bool { - matches!(self, Self::Basic | Self::Cors) + pub(super) fn is_readable(&self) -> bool { + matches!(self, Self::Basic | Self::Cors(_)) } } @@ -926,6 +926,7 @@ pub(super) struct ServiceWorkerControllerChangeCompletion { pub(super) struct StreamingSubresourceFetchState { pub(super) response_filter: Option, + pub(super) skip_fetch_security_validation: bool, pub(super) pending: PendingSubresourceFetchState, pub(super) request_url: Url, pub(super) request_method: String, @@ -941,12 +942,13 @@ pub(super) struct StreamingSubresourceFetchState { impl StreamingSubresourceFetchState { pub(super) fn needs_orb_body_validation(&self) -> bool { - crate::network_host::fetch_response_needs_orb_body_validation( - &self.pending.info.document_url, - &self.head.final_url, - &self.head.headers, - self.pending.request_mode, - ) + !self.skip_fetch_security_validation + && crate::network_host::fetch_response_needs_orb_body_validation( + &self.pending.info.document_url, + &self.head.final_url, + &self.head.headers, + self.pending.request_mode, + ) } } diff --git a/moli-renderer-v8/src/worker/global_scope/fetch.rs b/moli-renderer-v8/src/worker/global_scope/fetch.rs index 8c649fa398..7d7c30ddb5 100644 --- a/moli-renderer-v8/src/worker/global_scope/fetch.rs +++ b/moli-renderer-v8/src/worker/global_scope/fetch.rs @@ -322,6 +322,8 @@ 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, fetch_id, network_request_headers, result, @@ -429,6 +431,8 @@ fn spawn_worker_fetch_service_worker( if !runtime.dispatch_controlled_fetch(dispatch) { let _ = completion_tx.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: None, + skip_fetch_security_validation: false, fetch_id, network_request_headers: None, result: Err("service worker fetch dispatch failed".to_owned()), @@ -463,6 +467,8 @@ fn spawn_worker_fetch_service_worker( Ok(ServiceWorkerDirectFetchResult::Response(response)) => { let _ = completion_tx.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: response.response_filter, + skip_fetch_security_validation: !response.from_network_fallback, fetch_id, network_request_headers: None, result: Ok(WorkerFetchResponse::Materialized(Box::new( @@ -474,6 +480,8 @@ fn spawn_worker_fetch_service_worker( Ok(ServiceWorkerDirectFetchResult::Failure(message)) => { let _ = completion_tx.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: None, + skip_fetch_security_validation: false, fetch_id, network_request_headers: None, result: Err(message), @@ -483,6 +491,8 @@ fn spawn_worker_fetch_service_worker( Err(_) => { let _ = completion_tx.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: None, + skip_fetch_security_validation: false, fetch_id, network_request_headers: None, result: Err("service worker fetch completion channel closed".to_owned()), @@ -728,6 +738,8 @@ pub(in crate::worker) fn fail_pending_worker_fetch( }; let _ = completion_tx.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: None, + skip_fetch_security_validation: false, fetch_id, network_request_headers: None, result: Err(error_text), @@ -753,6 +765,8 @@ pub(in crate::worker) fn fail_pending_worker_fetch_auth( }; let _ = completion_tx.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: None, + skip_fetch_security_validation: false, fetch_id, network_request_headers: None, result: Err(error_text), @@ -793,6 +807,8 @@ pub(in crate::worker) fn fulfill_pending_worker_fetch( }; let _ = completion.0.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: None, + skip_fetch_security_validation: false, fetch_id: completion.2, network_request_headers: None, result: Ok(WorkerFetchResponse::Materialized(Box::new(completion.1))), @@ -834,6 +850,8 @@ pub(in crate::worker) fn continue_pending_worker_fetch_response( }; let _ = completion.0.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: completion.1.response_filter, + skip_fetch_security_validation: completion.1.skip_fetch_security_validation, fetch_id: completion.2, network_request_headers: None, result: Ok(WorkerFetchResponse::Streamed { @@ -866,6 +884,8 @@ pub(in crate::worker) fn fail_pending_worker_fetch_response( }; let _ = completion_tx.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: None, + skip_fetch_security_validation: false, fetch_id, network_request_headers: None, result: Err(error_text), @@ -899,6 +919,8 @@ pub(in crate::worker) fn fulfill_pending_worker_fetch_response( }; let _ = completion.0.send(WorkerFetchEvent::Completion(Box::new( WorkerFetchCompletion { + response_filter: None, + skip_fetch_security_validation: false, fetch_id: completion.2, network_request_headers: None, result: Ok(WorkerFetchResponse::Materialized(Box::new(completion.1))), @@ -2626,13 +2648,14 @@ pub(in crate::worker) fn start_worker_streaming_fetch( &started.head.headers, pending.request_mode, ); - let mut observable_head = started.head.clone(); - observable_head.headers = crate::network_host::FetchResponseRequest { + let observable_head = started.head.clone(); + let response_filter = crate::network_host::FetchResponseRequest { method: &pending.request_method, mode: pending.request_mode, redirect_mode: pending.redirect_mode, } - .filter_response_headers( + .network_response_filter( + &pending.document_url, &request_origin, &observable_head, pending.credentials_mode, @@ -2645,6 +2668,7 @@ pub(in crate::worker) fn start_worker_streaming_fetch( pending.request_method.clone(), pending.redirect_mode, observable_head, + response_filter, )) } }; @@ -2666,9 +2690,10 @@ pub(in crate::worker) fn start_worker_streaming_fetch( request_method, redirect_mode, observable_head, + response_filter, )) = response_input { - let response_obj = build_fetch_response_object_from_stream_for_request_mode( + let response_obj = crate::network_host::build_fetch_response_object_from_stream_for_request_mode_with_filter( scope, &document_url, crate::network_host::FetchResponseRequest { @@ -2678,6 +2703,7 @@ pub(in crate::worker) fn start_worker_streaming_fetch( }, observable_head, started.body_source_id, + Some(response_filter), ); let resolver = v8::Local::new(scope, &resolver); let _ = resolver.resolve(scope, response_obj.into()); @@ -2892,6 +2918,8 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( { let response_body = response.subresource_response_body(); pending.paused_response = Some(PausedWorkerSubresourceResponse { + response_filter: completion.response_filter.clone(), + skip_fetch_security_validation: completion.skip_fetch_security_validation, head: response_head.clone(), body: response_body.clone(), }); @@ -2947,6 +2975,8 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( from_cache: response_head.from_cache, }; pending.paused_response = Some(PausedWorkerSubresourceResponse { + response_filter: completion.response_filter.clone(), + skip_fetch_security_validation: completion.skip_fetch_security_validation, head: response_head, body: response_body, }); @@ -2975,36 +3005,40 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( Ok(response) => { let response_head = response.head(); let request_origin = WebOrigin::from_url(&pending.document_url); - let security_validation = match &response { - WorkerFetchResponse::Materialized(response) => { - validate_fetch_response_security_policy_with_body_classified_for_origin( - &pending.document_url, - &request_origin, - &response_head, - response.body_bytes(), - pending.request_mode, - pending.credentials_mode, - pending.policy_context, - ) - } - WorkerFetchResponse::Streamed { body, .. } => body - .try_bytes() - .map_err(|error| { - FetchResponseSecurityViolation::Rejected(format!( - "fetch: failed to read response body: {error}" - )) - }) - .and_then(|body_bytes| { + let security_validation = if completion.skip_fetch_security_validation { + Ok(()) + } else { + match &response { + WorkerFetchResponse::Materialized(response) => { validate_fetch_response_security_policy_with_body_classified_for_origin( &pending.document_url, &request_origin, &response_head, - &body_bytes, + response.body_bytes(), pending.request_mode, pending.credentials_mode, pending.policy_context, ) - }), + } + WorkerFetchResponse::Streamed { body, .. } => body + .try_bytes() + .map_err(|error| { + FetchResponseSecurityViolation::Rejected(format!( + "fetch: failed to read response body: {error}" + )) + }) + .and_then(|body_bytes| { + validate_fetch_response_security_policy_with_body_classified_for_origin( + &pending.document_url, + &request_origin, + &response_head, + &body_bytes, + pending.request_mode, + pending.credentials_mode, + pending.policy_context, + ) + }), + } }; let opaque_response_blocked = match security_validation { Ok(()) => false, @@ -3053,40 +3087,43 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( mode: pending.request_mode, redirect_mode: pending.redirect_mode, }; - let filtered_headers = response_request.filter_response_headers( - &request_origin, - &response_head, - pending.credentials_mode, - ); + let response_filter = completion.response_filter.or_else(|| { + Some(response_request.network_response_filter( + &pending.document_url, + &request_origin, + &response_head, + pending.credentials_mode, + )) + }); let response_obj = match response.into_fetch_parts() { - WorkerFetchResponseParts::Materialized { mut head, body } => { - head.headers = filtered_headers; + WorkerFetchResponseParts::Materialized { head, body } => { let body = if opaque_response_blocked { ResponseBody::materialized_bytes(Vec::new()) } else { *body }; - build_fetch_response_object_from_body_source_for_request_mode( + crate::network_host::build_fetch_response_object_from_body_source_for_request_mode_with_filter( scope, &pending.document_url, response_request, head, body, + response_filter, ) } - WorkerFetchResponseParts::Subresource { mut head, body } => { - head.headers = filtered_headers; + WorkerFetchResponseParts::Subresource { head, body } => { let body = if opaque_response_blocked { SubresourceResponseBody::from_bytes(Vec::new()) } else { body }; - build_fetch_response_object_from_subresource_body_for_request_mode( + crate::network_host::build_fetch_response_object_from_subresource_body_for_request_mode_with_filter( scope, &pending.document_url, response_request, head, body, + response_filter, ) } }; diff --git a/moli-renderer-v8/src/worker/global_scope/mod.rs b/moli-renderer-v8/src/worker/global_scope/mod.rs index cd70819f3b..23151f4aa9 100644 --- a/moli-renderer-v8/src/worker/global_scope/mod.rs +++ b/moli-renderer-v8/src/worker/global_scope/mod.rs @@ -72,9 +72,6 @@ use crate::network_host::{ XHR_SEND_FLAG_SLOT, XHR_TIMEOUT_SLOT, XHR_TIMEOUT_START_MS_SLOT, XHR_TIMEOUT_TIMER_SLOT, XHR_URL_SLOT, XHR_WITH_CREDENTIALS_SLOT, append_default_body_content_type, apply_xhr_failure, apply_xhr_response, apply_xhr_response_body_source, apply_xhr_timeout, apply_xhr_upload_event, - build_fetch_response_object_from_body_source_for_request_mode, - build_fetch_response_object_from_stream_for_request_mode, - build_fetch_response_object_from_subresource_body_for_request_mode, capture_xhr_upload_listener_flag, close_pending_network_body_stream, cors_request_origin_after_redirects, dispatch_xhr_loadstart, enqueue_pending_network_body_chunk, error_pending_network_body_stream_with_reason, @@ -1125,6 +1122,8 @@ pub(super) enum WorkerFetchEvent { } pub(super) struct WorkerFetchCompletion { + response_filter: Option, + skip_fetch_security_validation: bool, fetch_id: u32, network_request_headers: Option>, result: Result, @@ -1241,6 +1240,8 @@ pub(super) struct PendingWorkerCspReport { } pub(super) struct PausedWorkerSubresourceResponse { + pub(super) response_filter: Option, + pub(super) skip_fetch_security_validation: bool, pub(super) head: ResponseHead, pub(super) body: SubresourceResponseBody, } diff --git a/moli-renderer-v8/src/worker/global_scope/xhr.rs b/moli-renderer-v8/src/worker/global_scope/xhr.rs index 3310e1004c..09da1e6ecc 100644 --- a/moli-renderer-v8/src/worker/global_scope/xhr.rs +++ b/moli-renderer-v8/src/worker/global_scope/xhr.rs @@ -1010,6 +1010,8 @@ pub(in crate::worker) fn drain_worker_xhr_completion( { let response_body = response.subresource_response_body(); pending.paused_response = Some(PausedWorkerSubresourceResponse { + response_filter: None, + skip_fetch_security_validation: false, head: response_head.clone(), body: response_body.clone(), }); @@ -1066,6 +1068,8 @@ pub(in crate::worker) fn drain_worker_xhr_completion( from_cache: response_head.from_cache, }; pending.paused_response = Some(PausedWorkerSubresourceResponse { + response_filter: None, + skip_fetch_security_validation: false, head: response_head, body: response_body, }); diff --git a/moli-renderer-v8/src/worker/thread/dispatch.rs b/moli-renderer-v8/src/worker/thread/dispatch.rs index b49a785f4d..b0c37b5992 100644 --- a/moli-renderer-v8/src/worker/thread/dispatch.rs +++ b/moli-renderer-v8/src/worker/thread/dispatch.rs @@ -3339,6 +3339,7 @@ fn service_worker_fetch_response_from_materialized( ) -> ServiceWorkerFetchResponse { let response = head.with_body(body); ServiceWorkerFetchResponse { + cors_exposed_header_names: response.cors_exposed_header_names, final_url: response.final_url, response_type: response.response_type, redirected: response.redirected, @@ -3353,6 +3354,7 @@ fn service_worker_fetch_response_head_from_materialized( head: &MaterializedResponseHead, ) -> MaterializedServiceWorkerFetchResponseHead { MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: head.cors_exposed_header_names.clone(), status_text: head.status_text.clone(), final_url: head.final_url.clone(), response_type: head.response_type.clone(), diff --git a/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs b/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs index 877153e2c4..33b3c061be 100644 --- a/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs +++ b/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs @@ -761,6 +761,7 @@ async fn service_worker_fetch_event_preload_response_resolves_network_response() request_mode: moli_fetch::RequestMode::Navigate, body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some( url::Url::parse("https://example.test/app/navigation.html") .expect("navigation preload response URL"), @@ -877,6 +878,7 @@ async fn service_worker_fetch_event_preload_response_opaqueredirect_exposes_requ request_mode: moli_fetch::RequestMode::Navigate, body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some(request_url), response_type: "default".to_owned(), redirected: false, @@ -1049,6 +1051,7 @@ async fn service_worker_fetch_event_preload_response_body_errors_after_response( request_mode: moli_fetch::RequestMode::Navigate, body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some( url::Url::parse("https://example.test/app/navigation.html") .expect("navigation preload response URL"), @@ -1164,6 +1167,7 @@ async fn service_worker_fetch_event_preload_response_body_completes_after_fetch_ request_mode: moli_fetch::RequestMode::Navigate, body_source_id, response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, final_url: Some( url::Url::parse("https://example.test/app/navigation.html") .expect("navigation preload response URL"), diff --git a/moli-storage-service/src/buckets.rs b/moli-storage-service/src/buckets.rs index d1aa97cb37..b0aeb33411 100644 --- a/moli-storage-service/src/buckets.rs +++ b/moli-storage-service/src/buckets.rs @@ -132,6 +132,7 @@ fn is_false(value: &bool) -> bool { #[derive(Debug, Clone, PartialEq)] pub struct StorageBucketCachedResponse { + pub cors_exposed_header_names: Option>, pub response_type: String, pub url: String, pub redirected: bool, @@ -388,6 +389,8 @@ struct StorageBucketCacheJson { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct StorageBucketCacheJsonEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + cors_exposed_header_names: Option>, usage_bytes: u64, #[serde( default = "default_cache_request_method", @@ -1773,6 +1776,8 @@ fn cache_entry_matches_query( query.ignore_vary // Opaque public headers are empty even though Cache retains the internal head. || matches!(entry.response.response_type.as_str(), "opaque" | "opaqueredirect") + || entry.response.response_type == "cors" && entry.response.cors_exposed_header_names.as_ref() + .is_some_and(|names| !names.iter().any(|name| name.eq_ignore_ascii_case("vary"))) || cached_response_vary_matches_request( &entry.response.headers, &entry.request.headers, @@ -2098,6 +2103,7 @@ fn load_storage_bucket_cache_file( headers: entry.request_headers, }, response: StorageBucketCachedResponse { + cors_exposed_header_names: entry.cors_exposed_header_names, response_type: entry.response_type, url: entry.url, redirected: entry.redirected, @@ -2129,6 +2135,7 @@ fn save_storage_bucket_cache_file( ( request_key.clone(), StorageBucketCacheJsonEntry { + cors_exposed_header_names: entry.response.cors_exposed_header_names.clone(), usage_bytes: entry.usage_bytes, request_method: entry.request.method.clone(), request_headers: entry.request.headers.clone(), @@ -2827,6 +2834,7 @@ mod tests { "cache", "request", StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: "https://quota.test/resource".to_owned(), redirected: false, @@ -2904,6 +2912,7 @@ mod tests { "cache", "request", StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: "https://default-quota.test/resource".to_owned(), redirected: false, @@ -3133,6 +3142,7 @@ mod tests { let identity = store.open_bucket("https://a.test", "bucket")?; assert!(store.open_cache_for_identity(&identity, "cache")?); let response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3178,6 +3188,7 @@ mod tests { let identity = store.open_bucket("https://a.test", "bucket")?; assert!(store.open_cache_for_identity(&identity, "cache")?); let first_response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3234,6 +3245,7 @@ mod tests { ); let replacement_response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3288,6 +3300,7 @@ mod tests { let identity = store.open_bucket("https://a.test", "bucket")?; assert!(store.open_cache_for_identity(&identity, "cache")?); let response = |body: &str, headers: Vec<(String, String)>| StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3380,6 +3393,7 @@ mod tests { .open_cache_handle_for_identity(&identity, "cache")? .expect("bucket should remain current"); let response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3444,6 +3458,7 @@ mod tests { )?; assert!(store.open_cache_for_identity(&identity, "cache")?); let original = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3453,6 +3468,7 @@ mod tests { body: b"small".to_vec(), }; let oversized = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3562,6 +3578,7 @@ mod tests { "global-cache", "/entry", StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: "https://a.test/entry".to_owned(), redirected: false, @@ -3800,6 +3817,7 @@ mod tests { "cache", "/cached.txt", StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3889,81 +3907,102 @@ mod tests { #[test] fn json_storage_bucket_store_persists_cache_entries() -> Result<()> { - let temp = TempStorePath::new("cache-persist"); - let cache_root = temp.cache_root(); - let response = StorageBucketCachedResponse { - response_type: "default".to_owned(), - url: String::new(), - redirected: false, - status: 202, - status_text: "Accepted".to_owned(), - headers: vec![("x-cache".to_owned(), "persisted".to_owned())], - body: b"profile cache body".to_vec(), - }; - { + for cors in [false, true] { + let temp = TempStorePath::new("cache-persist"); + let cache_root = temp.cache_root(); + let response = StorageBucketCachedResponse { + cors_exposed_header_names: cors.then(|| vec!["x-cache".to_owned()]), + response_type: if cors { "cors" } else { "default" }.to_owned(), + url: if cors { + "https://remote.test/cached.txt" + } else { + "" + } + .to_owned(), + redirected: false, + status: 202, + status_text: "Accepted".to_owned(), + headers: if cors { + vec![ + ("x-cache".to_owned(), "persisted".to_owned()), + ( + "cross-origin-resource-policy".to_owned(), + "same-origin".to_owned(), + ), + ("vary".to_owned(), "*".to_owned()), + ] + } else { + vec![("x-cache".to_owned(), "persisted".to_owned())] + }, + body: b"profile cache body".to_vec(), + }; + { + let store = + new_shared_json_storage_bucket_store_with_cache_root(&temp.path, &cache_root)?; + let mut store = store.lock(); + let identity = store.open_bucket("https://a.test", "bucket")?; + assert!(store.open_cache_for_identity(&identity, "cache")?); + assert_eq!( + store.put_cache_entry_for_identity( + &identity, + "cache", + "/cached.txt", + response.clone(), + 64, + 0, + )?, + StorageBucketCachePutOutcome::Stored + ); + } + + assert!( + cache_root.exists(), + "profile-backed CacheStorage root should be written" + ); + let (next, previous) = storage_bucket_cache_replacement_paths(&cache_root)?; + assert!( + !next.exists(), + "profile-backed CacheStorage replacement root should not be left after save" + ); + assert!( + !previous.exists(), + "profile-backed CacheStorage previous root should not be left after save" + ); + + { + let store = + new_shared_json_storage_bucket_store_with_cache_root(&temp.path, &cache_root)?; + let mut store = store.lock(); + let identity = store.open_bucket("https://a.test", "bucket")?; + assert_eq!( + store.cache_names_for_identity(&identity), + Some(vec!["cache".to_owned()]) + ); + let matched = store + .match_cache_entry_for_identity(&identity, "cache", "/cached.txt") + .flatten() + .expect("cache entry should persist across reopen"); + assert_eq!(matched, response); + assert_eq!(store.cache_usage_for_identity(&identity), Some(64)); + assert_eq!( + store.delete_cache_for_identity(&identity, "cache")?, + Some(true) + ); + } + let store = new_shared_json_storage_bucket_store_with_cache_root(&temp.path, &cache_root)?; let mut store = store.lock(); let identity = store.open_bucket("https://a.test", "bucket")?; - assert!(store.open_cache_for_identity(&identity, "cache")?); - assert_eq!( - store.put_cache_entry_for_identity( - &identity, - "cache", - "/cached.txt", - response.clone(), - 64, - 0, - )?, - StorageBucketCachePutOutcome::Stored - ); + assert_eq!(store.cache_names_for_identity(&identity), Some(Vec::new())); } - - assert!( - cache_root.exists(), - "profile-backed CacheStorage root should be written" - ); - let (next, previous) = storage_bucket_cache_replacement_paths(&cache_root)?; - assert!( - !next.exists(), - "profile-backed CacheStorage replacement root should not be left after save" - ); - assert!( - !previous.exists(), - "profile-backed CacheStorage previous root should not be left after save" - ); - - { - let store = - new_shared_json_storage_bucket_store_with_cache_root(&temp.path, &cache_root)?; - let mut store = store.lock(); - let identity = store.open_bucket("https://a.test", "bucket")?; - assert_eq!( - store.cache_names_for_identity(&identity), - Some(vec!["cache".to_owned()]) - ); - let matched = store - .match_cache_entry_for_identity(&identity, "cache", "/cached.txt") - .flatten() - .expect("cache entry should persist across reopen"); - assert_eq!(matched, response); - assert_eq!(store.cache_usage_for_identity(&identity), Some(64)); - assert_eq!( - store.delete_cache_for_identity(&identity, "cache")?, - Some(true) - ); - } - - let store = new_shared_json_storage_bucket_store_with_cache_root(&temp.path, &cache_root)?; - let mut store = store.lock(); - let identity = store.open_bucket("https://a.test", "bucket")?; - assert_eq!(store.cache_names_for_identity(&identity), Some(Vec::new())); Ok(()) } #[test] fn json_storage_bucket_cache_crash_points_recover_one_committed_root() -> Result<()> { let old_response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3973,6 +4012,7 @@ mod tests { body: b"old committed cache body".to_vec(), }; let new_response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -4057,6 +4097,7 @@ mod tests { let temp = TempStorePath::new("cache-promote-next"); let cache_root = temp.cache_root(); let response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -4109,6 +4150,7 @@ mod tests { let temp = TempStorePath::new("cache-restore-previous"); let cache_root = temp.cache_root(); let response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -4164,6 +4206,7 @@ mod tests { let indexed_db_manager = new_indexed_db_manager(None).map_err(anyhow::Error::msg)?; let storage_service = StorageService::on_disk(temp.opfs_root())?; let response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -4526,6 +4569,7 @@ mod tests { let bucket_name = "bucket"; let bucket_id = StorageBucketId::new(17).unwrap(); let response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: "https://a.test/cached".to_owned(), redirected: false,