From a534d047f2c8f9501188f99ead50ae8a353bb864 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 13 Sep 2026 03:38:51 +0800 Subject: [PATCH] fix(fetch): preserve service worker response types and CORS headers Carry the original response filter and validation state through Window streams and Worker completions, including paused responses. Synthesized responses remain basic, and already fetched bodies are not filtered by ORB again. Keep client CORP/COEP checks against actual network response URLs. Retain internal headers separately from the CORS public header view through clone, deferred Cache.put, persistent Cache storage and respondWith. Match Vary using the public header view and keep filtered cached headers immutable. --- moli-core/tests/fetch/security.rs | 73 ++++++ moli-core/tests/scripts/integrity.rs | 10 +- .../window_runtime/navigator.rs | 49 +++- moli-renderer-v8/src/network_host.rs | 17 +- moli-renderer-v8/src/network_host/response.rs | 15 +- .../src/network_host/response/cors.rs | 115 ++++++---- .../src/network_host/response/materialize.rs | 165 +++++++++----- moli-renderer-v8/src/planning.rs | 4 +- .../src/script_vm/subresource_fetch.rs | 40 ++-- .../misc/extracted/service_worker_events.rs | 10 +- .../tests/dom_xhr/opaque_response.rs | 168 ++++++++++++++ .../tests/dom_xhr/redirect_filter.rs | 6 +- .../src/script_vm/tests/dom_xhr/xhr.rs | 5 + .../src/script_vm/tests/indexed_db.rs | 1 + moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../src/service_worker_runtime/events.rs | 2 + .../service/event_dispatch.rs | 1 + .../service/fetch_settlement.rs | 214 ++++++++++-------- .../service/tests/extracted/fetch_events.rs | 8 +- moli-renderer-v8/src/stylesheet_blocking.rs | 6 +- .../src/stylesheet_runtime/connected.rs | 2 +- moli-renderer-v8/src/types.rs | 22 +- .../src/worker/global_scope/fetch.rs | 111 +++++---- .../src/worker/global_scope/mod.rs | 7 +- .../src/worker/global_scope/xhr.rs | 4 + .../src/worker/thread/dispatch.rs | 2 + .../src/worker/thread/tests/lifecycle.rs | 4 + moli-storage-service/src/buckets.rs | 176 ++++++++------ 28 files changed, 869 insertions(+), 369 deletions(-) diff --git a/moli-core/tests/fetch/security.rs b/moli-core/tests/fetch/security.rs index 0a5503aaa6..fabe7a47bc 100644 --- a/moli-core/tests/fetch/security.rs +++ b/moli-core/tests/fetch/security.rs @@ -119,6 +119,35 @@ fn fixture_response(request: &IncomingRequest) -> (u16, String, String) { .to_owned(), ); } + if url.path() == "/sandboxed-fetch-filter" { + return ( + 200, + "Content-Type: text/html\r\nContent-Security-Policy: sandbox allow-scripts\r\n" + .to_owned(), + r#""# + .to_owned(), + ); + } + if url.path() == "/fetch-filter-response" { + return ( + 200, + format!( + "{cors}Content-Type: text/plain\r\nCache-Control: no-store\r\nCross-Origin-Resource-Policy: cross-origin\r\nAccess-Control-Expose-Headers: X-Visible\r\nX-Visible: visible\r\nX-Private: secret\r\n" + ), + "ok".to_owned(), + ); + } if url.path() == "/sandboxed-preload" { return ( 200, @@ -819,3 +848,47 @@ async fn csp_sandboxed_child_script_and_modulepreload_preserve_opaque_origin() - } Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn csp_sandboxed_fetch_keeps_cors_and_opaque_response_filters() -> Result<()> { + let server = SecurityServers::spawn().await?; + let browser = Browser::new(BrowserConfig::default())?; + let mut page = browser.fetch(&format!("{}/page", server.origin)).await?; + let observed = results( + page.evaluate_runtime_expression_with_await_async( + r#"new Promise(resolve => { + const frame = document.createElement('iframe'); + const handler = event => { + if (event.source !== frame.contentWindow) return; + removeEventListener('message', handler); + frame.remove(); + resolve(event.data); + }; + addEventListener('message', handler); + frame.src = '/sandboxed-fetch-filter'; + document.body.append(frame); + })"#, + true, + ) + .await?, + )?; + assert_eq!( + observed, + json!([ + {"mode":"cors", "type":"cors", "status":200, "visible":"visible", "private":null, "body":"ok"}, + {"mode":"no-cors", "type":"opaque", "status":0, "visible":null, "private":null, "body":""} + ]) + ); + let requests = server.requests.lock(); + let fetches = requests + .iter() + .filter(|request| request.path == "/fetch-filter-response") + .collect::>(); + assert_eq!(fetches.len(), 2, "{requests:?}"); + assert_eq!(fetches[0].origin.as_deref(), Some("null"), "{fetches:?}"); + assert!( + fetches.iter().all(|request| request.cookie.is_none()), + "{fetches:?}" + ); + Ok(()) +} diff --git a/moli-core/tests/scripts/integrity.rs b/moli-core/tests/scripts/integrity.rs index fc9134b20a..b803c1f850 100644 --- a/moli-core/tests/scripts/integrity.rs +++ b/moli-core/tests/scripts/integrity.rs @@ -1069,20 +1069,18 @@ async fn service_worker_redirect_preserves_worker_response_filter() -> Result<() }})()"#, cross = servers.cross_origin), true, ).await?; let results: serde_json::Value = serde_json::from_str(result["value"].as_str().unwrap())?; + // A synthesized Response has an empty URL list and remains basic after + // the intercepted redirect, including its ordinary response headers. for kind in ["basic", "basic-buffered", "cors", "default"] { assert_eq!(results[kind]["body"], SCRIPT, "{kind}: {results}"); assert_eq!( results[kind]["type"], - if kind.starts_with("basic") { - "basic" - } else { - "cors" - }, + if kind != "cors" { "basic" } else { "cors" }, "{kind}: {results}" ); assert_eq!( results[kind]["privateHeader"], - if kind.starts_with("basic") { + if kind != "cors" { serde_json::json!("yes") } else { serde_json::Value::Null 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 72ea400cf6..197f3c7475 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,16 +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, - &moli_fetch::headers_to_byte_strings(&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, &moli_fetch::headers_to_byte_strings(&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 3213d3a9bb..d48eee7466 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -147,11 +147,9 @@ pub(in crate::network_host) use self::request_scope::{ 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, 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, fetch_response_needs_orb_body_validation, @@ -159,12 +157,11 @@ pub(crate) use self::response::{ materialize_response_object_body, materialize_response_object_body_with_chunk_callback, materialize_response_object_internal_head, materialized_body_bytes_from_value, network_response_filter, response_constructor_callback, response_has_null_body, - validate_cors_preflight_response, validate_cors_response_chain, - validate_cross_origin_embedder_and_document_isolation_policy, + set_filtered_response_internal_head, validate_cors_preflight_response, + validate_cors_response_chain, validate_cross_origin_embedder_and_document_isolation_policy, validate_cross_origin_resource_policy, validate_fetch_response_headers, validate_fetch_response_security_policy, validate_fetch_response_security_policy_with_body, - validate_fetch_response_security_policy_with_body_classified, - validate_opaque_response_blocking_with_body, validated_opaque_response_body, + validate_fetch_response_security_policy_with_body_classified, validated_opaque_response_body, }; #[cfg(test)] pub(crate) use self::response::{materialize_response_object, materialize_response_object_head}; @@ -303,3 +300,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 823a48580f..dccff665a1 100644 --- a/moli-renderer-v8/src/network_host/response.rs +++ b/moli-renderer-v8/src/network_host/response.rs @@ -24,22 +24,25 @@ pub(crate) use self::cors::{ validate_cross_origin_embedder_and_document_isolation_policy, validate_cross_origin_resource_policy, validate_fetch_response_headers, validate_fetch_response_security_policy, validate_fetch_response_security_policy_with_body, - validate_fetch_response_security_policy_with_body_classified, - validate_opaque_response_blocking_with_body, validated_opaque_response_body, + validate_fetch_response_security_policy_with_body_classified, validated_opaque_response_body, }; 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, + network_response_filter, set_filtered_response_internal_head, }; #[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/cors.rs b/moli-renderer-v8/src/network_host/response/cors.rs index 3e9696495c..bc0f5b78af 100644 --- a/moli-renderer-v8/src/network_host/response/cors.rs +++ b/moli-renderer-v8/src/network_host/response/cors.rs @@ -795,54 +795,73 @@ mod tests { let home = url("https://page.test/a"); let away = url("https://script.test/b"); let elsewhere = url("https://other.test/c"); - let origin = WebOrigin::from_url(&home); - for (redirects, serialized_origin) in [ - (vec![(&home, &away)], origin.ascii_serialization()), - (vec![(&away, &elsewhere)], "null"), - (vec![(&home, &away), (&away, &home)], "null"), - ] { - let final_url = redirects.last().expect("redirect target").1.clone(); - let mut head = header_response( - final_url, - vec![( - "Access-Control-Allow-Origin".to_owned(), - serialized_origin.to_owned(), - )], - ); - head.redirected = true; - head.redirect_chain = redirects - .into_iter() - .map(|(from, to)| moli_fetch::RedirectInfo { - source: RedirectSource::Network, - from_url: from.clone(), - to_url: to.clone(), - status: 302, - headers: vec![( - "Access-Control-Allow-Origin".to_owned(), - origin.ascii_serialization().to_owned(), - )], - network_extra_info_available: false, - request_extra_info: None, - response_extra_info: None, - redirect_has_extra_info: false, - request_cookie_report: None, - cookie_set_reports: Vec::new(), - from_cache: false, - negotiated_http_version: None, - }) - .collect(); - assert_eq!( - head.url_list().serialized_origin(&origin), - serialized_origin - ); - validate_cors_response_chain(&origin, &head, RequestCredentialsMode::SameOrigin) - .expect("each response must be authorized against its own request hop origin"); - head.headers.clear(); - assert!( - validate_cors_response_chain(&origin, &head, RequestCredentialsMode::SameOrigin) - .is_err(), - "returning to the initial origin must not bypass CORS" - ); + for origin in [WebOrigin::from_url(&home), WebOrigin::Opaque] { + for source in [ + RedirectSource::Network, + RedirectSource::ServiceWorker, + RedirectSource::Internal, + ] { + for (urls, tuple_origin) in [ + (vec![&home, &away], "https://page.test"), + (vec![&away, &elsewhere], "null"), + (vec![&home, &away, &home], "null"), + ] { + let expected_origin = if origin.is_opaque() { + "null" + } else { + tuple_origin + }; + let mut head = header_response( + (*urls.last().unwrap()).clone(), + vec![("Access-Control-Allow-Origin".into(), expected_origin.into())], + ); + head.redirected = true; + head.redirect_chain = urls + .windows(2) + .map(|pair| moli_fetch::RedirectInfo { + source, + from_url: pair[0].clone(), + to_url: pair[1].clone(), + status: 302, + // The response to each hop is checked before its redirect + // changes the serialized origin for the following request. + headers: if source == RedirectSource::Network { + vec![( + "Access-Control-Allow-Origin".into(), + origin.ascii_serialization().into(), + )] + } else { + Vec::new() + }, + network_extra_info_available: false, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: false, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }) + .collect(); + assert_eq!(head.url_list().serialized_origin(&origin), expected_origin); + assert!(head.url_list().has_cross_origin_url(&origin)); + validate_cors_response_chain( + &origin, + &head, + RequestCredentialsMode::SameOrigin, + ) + .expect("authorize network hops using their individual request origins"); + head.headers.clear(); + validate_cors_response_chain( + &origin, + &head, + RequestCredentialsMode::SameOrigin, + ) + .expect_err( + "returning to the client origin cannot bypass final CORS validation", + ); + } + } } } diff --git a/moli-renderer-v8/src/network_host/response/materialize.rs b/moli-renderer-v8/src/network_host/response/materialize.rs index 19e2a7f2cb..4949d2dd3a 100644 --- a/moli-renderer-v8/src/network_host/response/materialize.rs +++ b/moli-renderer-v8/src/network_host/response/materialize.rs @@ -18,20 +18,24 @@ pub(crate) struct FetchResponseRequest<'a> { } impl FetchResponseRequest<'_> { - pub(crate) fn filter_response_headers( + pub(crate) fn network_response_filter( self, 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(request_origin, head, credentials_mode) + ) -> crate::types::AsyncSubresourceFetchResponseFilter { + use crate::types::AsyncSubresourceFetchResponseFilter as Filter; + let filter = response_filter(request_origin, head, self); + match compute_fetch_response_type(request_origin, head, filter) { + "opaque" => Filter::Opaque, + "opaqueredirect" => Filter::OpaqueRedirect, + "cors" => Filter::Cors( + filter_cors_exposed_response_headers(request_origin, head, credentials_mode) + .into_iter() + .map(|(name, _)| name) + .collect(), + ), + _ => Filter::Basic, } } } @@ -49,17 +53,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 @@ -108,6 +106,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) } @@ -160,7 +159,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 { @@ -176,22 +179,25 @@ fn filtered_response_url(head: &moli_fetch::ResponseHead, filter: FetchResponseF } 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 +249,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 +271,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 +280,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 +322,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,6 +390,7 @@ 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) @@ -356,6 +402,7 @@ fn build_fetch_response_object_from_stream_for_request_mode_with_surface_url<'s> &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 @@ -377,6 +424,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); @@ -395,22 +443,35 @@ 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(), - &moli_fetch::headers_to_byte_strings(&head.headers), - ); - } + set_filtered_response_internal_head(scope, obj, head.status, head.status_text(), &moli_fetch::headers_to_byte_strings(&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(&moli_fetch::headers_to_byte_strings(&header_entries), HeadersGuard::Response); + let headers_obj = + build_headers_object_with_state(scope, &headers, HeadersGuard::Response, true); + 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)], @@ -430,6 +491,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>( @@ -445,19 +509,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( - &moli_fetch::headers_to_byte_strings(header_entries), - HeadersGuard::Response, - ); - let headers_obj = - build_headers_object_with_state(scope, &headers, HeadersGuard::Response, true); - 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 { @@ -517,6 +568,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, @@ -528,6 +580,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, @@ -539,6 +592,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, @@ -643,6 +697,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), @@ -660,9 +716,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 6277c80a1b..446f682e2a 100644 --- a/moli-renderer-v8/src/planning.rs +++ b/moli-renderer-v8/src/planning.rs @@ -427,7 +427,7 @@ pub(crate) async fn load_service_worker_aware_external_script_source_outcome( ClassicScriptResponseProvenance::Network } else { ClassicScriptResponseProvenance::ServiceWorker { - filter: response.response_filter, + filter: response.response_filter.clone(), } }; external_script_source_load_outcome_from_response_inner( @@ -554,7 +554,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 43aef0448c..80bc19185d 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch.rs @@ -3850,7 +3850,7 @@ impl ScriptVm { record_started, ); let mut observable_response = response; - if pending.info.resource_type == SubresourceResourceType::Xhr && !response_filter.is_some_and(|filter| filter.is_readable()) { + if pending.info.resource_type == SubresourceResourceType::Xhr && !response_filter.as_ref().is_some_and(|filter| filter.is_readable()) { observable_response.headers = crate::network_host::filter_cors_exposed_response_headers( &pending.request_origin, @@ -3873,13 +3873,11 @@ impl ScriptVm { mode: pending.request_mode, redirect_mode, }; - if !response_filter.is_some_and(|filter| filter.is_readable()) { - head.headers = response_request.filter_response_headers( - &pending.request_origin, - &head, - pending.credentials_mode, - ); - } + let response_filter = response_filter.or_else(|| Some(response_request.network_response_filter( + &pending.request_origin, + &head, + pending.credentials_mode, + ))); if let Some(status_text) = response_status_text { head.status_text = Some(status_text); } @@ -4698,6 +4696,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, @@ -4841,15 +4840,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( &pending.request_origin, @@ -5038,7 +5034,7 @@ impl ScriptVm { } let mut observable_head = started.head.clone(); - if pending.info.resource_type == SubresourceResourceType::Xhr && !started.response_filter.is_some_and(|filter| filter.is_readable()) { + if pending.info.resource_type == SubresourceResourceType::Xhr && !started.response_filter.as_ref().is_some_and(|filter| filter.is_readable()) { observable_head.headers = crate::network_host::filter_cors_exposed_response_headers( &pending.request_origin, &observable_head, @@ -5083,6 +5079,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(), @@ -5134,20 +5131,18 @@ impl ScriptVm { mode: pending.request_mode, redirect_mode: fetch.redirect_mode(), }; - if !started.response_filter.is_some_and(|filter| filter.is_readable()) { - observable_head.headers = response_request.filter_response_headers( - &pending.request_origin, - &observable_head, - pending.credentials_mode, - ); - } + let response_filter = started.response_filter.clone().or_else(|| Some(response_request.network_response_filter( + &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()); } @@ -5248,6 +5243,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/extracted/service_worker_events.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/service_worker_events.rs index 6c01058e66..22a162d0fb 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/service_worker_events.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/service_worker_events.rs @@ -1003,7 +1003,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"}})); }); "#, ), @@ -1013,7 +1013,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", @@ -1021,7 +1023,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)); } @@ -1070,7 +1072,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/opaque_response.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/opaque_response.rs index e1ad10f88e..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] { 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 ce47b5a40f..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,9 +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(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 42a23526fd..ac2cb509ff 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 @@ -3069,6 +3069,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 { redirect_headers: None, request_origin: moli_url::WebOrigin::from_url(&document_url), @@ -3362,6 +3363,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 { redirect_headers: None, request_origin: moli_url::WebOrigin::from_url( @@ -3530,6 +3532,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 { redirect_headers: None, request_origin: moli_url::WebOrigin::from_url(&document_url), @@ -3635,6 +3638,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 { redirect_headers: None, request_origin: moli_url::WebOrigin::from_url( @@ -4268,6 +4272,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 { redirect_headers: None, request_origin: moli_url::WebOrigin::from_url( 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 f6a8bd6ce0..7525e250cc 100644 --- a/moli-renderer-v8/src/script_vm/tests/indexed_db.rs +++ b/moli-renderer-v8/src/script_vm/tests/indexed_db.rs @@ -3049,6 +3049,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 dc2a0ff23f..75df2e1a70 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -353,6 +353,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/service_worker_runtime/events.rs b/moli-renderer-v8/src/service_worker_runtime/events.rs index b3097c7356..839e9fbd40 100644 --- a/moli-renderer-v8/src/service_worker_runtime/events.rs +++ b/moli-renderer-v8/src/service_worker_runtime/events.rs @@ -629,6 +629,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, @@ -695,6 +696,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/event_dispatch.rs b/moli-renderer-v8/src/service_worker_runtime/service/event_dispatch.rs index d8523c0c5b..3964a28d2b 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 @@ -63,6 +63,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 47bbe0cbd7..2dbfe85d9d 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,9 +606,13 @@ 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; } @@ -760,57 +775,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, Vec)], ) -> 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, @@ -842,7 +816,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()); } @@ -872,7 +854,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(), @@ -885,26 +867,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)] @@ -1264,6 +1253,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, @@ -1280,7 +1270,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"); @@ -1364,6 +1357,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, @@ -1402,6 +1396,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, @@ -1449,6 +1444,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, @@ -1526,6 +1522,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, @@ -1550,8 +1547,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); @@ -1587,6 +1587,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, @@ -1632,6 +1633,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, @@ -1676,6 +1678,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, @@ -1719,6 +1722,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, @@ -1767,6 +1771,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, @@ -1890,6 +1895,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, @@ -1951,6 +1957,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, @@ -2058,6 +2065,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, @@ -2132,6 +2140,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, @@ -2179,6 +2188,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, @@ -2246,6 +2256,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, @@ -2293,6 +2304,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, @@ -2339,6 +2351,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, @@ -2401,6 +2414,7 @@ mod tests { run.clone(), ), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + cors_exposed_header_names: None, final_url: None, response_type: "opaque".to_owned(), redirected: false, @@ -2448,6 +2462,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, @@ -2501,6 +2516,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, @@ -2564,6 +2580,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, @@ -2623,6 +2640,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, @@ -2681,6 +2699,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, @@ -2739,6 +2758,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, @@ -2798,6 +2818,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, @@ -2851,6 +2872,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, @@ -2909,6 +2931,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, @@ -2966,6 +2989,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, @@ -3017,6 +3041,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, @@ -3076,6 +3101,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, @@ -3144,6 +3170,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, @@ -3209,6 +3236,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, @@ -3238,7 +3266,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); @@ -3260,27 +3288,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(), b"application/json".to_vec())], - 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] @@ -3306,6 +3337,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, @@ -3319,7 +3351,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"); @@ -3350,6 +3385,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/service_worker_runtime/service/tests/extracted/fetch_events.rs b/moli-renderer-v8/src/service_worker_runtime/service/tests/extracted/fetch_events.rs index 4272823d3c..8c58f23d43 100644 --- a/moli-renderer-v8/src/service_worker_runtime/service/tests/extracted/fetch_events.rs +++ b/moli-renderer-v8/src/service_worker_runtime/service/tests/extracted/fetch_events.rs @@ -1072,8 +1072,10 @@ fn navigation_preload_completion_keeps_response_started_preload_alive() { ); assert!( - service - .mark_navigation_preload_response_started(event_id, &test_run_owner(version_id, &run),) + service.mark_navigation_preload_response_started( + event_id, + &test_run_owner(version_id, &run), + ) ); assert!( !navigation_preload_cancel_handle.is_cancelled(), @@ -1084,6 +1086,7 @@ fn navigation_preload_completion_keeps_response_started_preload_alive() { 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(), b"text/plain".to_vec())], @@ -1381,6 +1384,7 @@ fn abort_controlled_fetch_drops_direct_worker_completion_and_ignores_late_comple 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(), b"text/plain".to_vec())], diff --git a/moli-renderer-v8/src/stylesheet_blocking.rs b/moli-renderer-v8/src/stylesheet_blocking.rs index 5175dfedf0..2ab81fd401 100644 --- a/moli-renderer-v8/src/stylesheet_blocking.rs +++ b/moli-renderer-v8/src/stylesheet_blocking.rs @@ -272,7 +272,7 @@ async fn fetch_stylesheet_readiness_with_request( } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] enum StylesheetResponseProvenance { Network, ServiceWorker { @@ -282,7 +282,7 @@ enum StylesheetResponseProvenance { impl StylesheetResponseProvenance { fn is_cors_same_origin( - self, + &self, request_origin: &moli_url::WebOrigin, request_url: &Url, head: &moli_fetch::ResponseHead, @@ -321,7 +321,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 0586ce7559..e8f7c3cd71 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/connected.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/connected.rs @@ -2174,7 +2174,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 e74bb36759..b5576d116b 100644 --- a/moli-renderer-v8/src/types.rs +++ b/moli-renderer-v8/src/types.rs @@ -737,17 +737,17 @@ impl From> for AsyncSubresourceF } } -#[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(_)) } } @@ -1013,6 +1013,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, @@ -1028,12 +1029,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.request_origin, + &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 2660c352ba..68501721ab 100644 --- a/moli-renderer-v8/src/worker/global_scope/fetch.rs +++ b/moli-renderer-v8/src/worker/global_scope/fetch.rs @@ -330,6 +330,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, @@ -437,6 +439,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()), @@ -473,6 +477,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( @@ -484,6 +490,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), @@ -493,6 +501,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()), @@ -774,6 +784,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), @@ -799,6 +811,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), @@ -840,6 +854,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))), @@ -881,6 +897,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 { @@ -913,6 +931,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), @@ -946,6 +966,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))), @@ -2716,13 +2738,13 @@ 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( &request_origin, &observable_head, pending.credentials_mode, @@ -2735,6 +2757,7 @@ pub(in crate::worker) fn start_worker_streaming_fetch( pending.request_method.clone(), pending.redirect_mode, observable_head, + response_filter, )) } }; @@ -2756,9 +2779,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 { @@ -2768,6 +2792,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()); @@ -2983,6 +3008,8 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( record.follow_redirects(&response_head); 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(), }); @@ -3038,6 +3065,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, }); @@ -3065,34 +3094,39 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( match completion.result { Ok(response) => { let response_head = response.head(); - let security_validation = match &response { - WorkerFetchResponse::Materialized(response) => { - validate_fetch_response_security_policy_with_body_classified( - &pending.document_url, - &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 request_origin = moli_url::WebOrigin::from_url(&pending.document_url); + let security_validation = if completion.skip_fetch_security_validation { + Ok(()) + } else { + match &response { + WorkerFetchResponse::Materialized(response) => { validate_fetch_response_security_policy_with_body_classified( - &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( + &request_origin, + &response_head, + &body_bytes, + pending.request_mode, + pending.credentials_mode, + pending.policy_context, + ) + }), + } }; let opaque_response_blocked = match security_validation { Ok(()) => false, @@ -3136,46 +3170,47 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( }, )); } - let request_origin = moli_url::WebOrigin::from_url(&pending.document_url); let response_request = crate::network_host::FetchResponseRequest { method: &pending.request_method, 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( + &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 d9d31efbe9..c911029e2d 100644 --- a/moli-renderer-v8/src/worker/global_scope/mod.rs +++ b/moli-renderer-v8/src/worker/global_scope/mod.rs @@ -71,9 +71,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, dispatch_xhr_loadstart, enqueue_pending_network_body_chunk, error_pending_network_body_stream_with_reason, extract_subresource_auth_challenge, @@ -1095,6 +1092,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, @@ -1234,6 +1233,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 2ed2e5d68b..b67f1e3757 100644 --- a/moli-renderer-v8/src/worker/global_scope/xhr.rs +++ b/moli-renderer-v8/src/worker/global_scope/xhr.rs @@ -1045,6 +1045,8 @@ pub(in crate::worker) fn drain_worker_xhr_completion( record.follow_redirects(&response_head); 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(), }); @@ -1101,6 +1103,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 4c8b0c28a3..d03cd252be 100644 --- a/moli-renderer-v8/src/worker/thread/dispatch.rs +++ b/moli-renderer-v8/src/worker/thread/dispatch.rs @@ -3364,6 +3364,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, @@ -3378,6 +3379,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 98084a2de2..f32d16622c 100644 --- a/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs +++ b/moli-renderer-v8/src/worker/thread/tests/lifecycle.rs @@ -763,6 +763,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"), @@ -879,6 +880,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, @@ -1051,6 +1053,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"), @@ -1166,6 +1169,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 12574e4e21..123f24f576 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", @@ -1774,6 +1777,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, @@ -2125,6 +2130,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, @@ -2156,6 +2162,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(), @@ -2854,6 +2861,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, @@ -2931,6 +2939,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, @@ -3160,6 +3169,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, @@ -3205,6 +3215,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, @@ -3261,6 +3272,7 @@ mod tests { ); let replacement_response = StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3315,6 +3327,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, Vec)>| StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3407,6 +3420,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, @@ -3471,6 +3485,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, @@ -3480,6 +3495,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, @@ -3589,6 +3605,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, @@ -3827,6 +3844,7 @@ mod tests { "cache", "/cached.txt", StorageBucketCachedResponse { + cors_exposed_header_names: None, response_type: "default".to_owned(), url: String::new(), redirected: false, @@ -3916,86 +3934,107 @@ 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(), b"persisted".to_vec()), + for cors in [false, true] { + let temp = TempStorePath::new("cache-persist"); + let cache_root = temp.cache_root(); + let mut 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(), b"persisted".to_vec()), + ( + "cross-origin-resource-policy".to_owned(), + b"same-origin".to_vec(), + ), + ("vary".to_owned(), b"*".to_vec()), + ] + } else { + vec![("x-cache".to_owned(), b"persisted".to_vec())] + }, + body: b"profile cache body".to_vec(), + }; + response.headers.extend([ ("x-bytes".to_owned(), b"\xff\x80\xa0".to_vec()), ("x-bytes".to_owned(), b"\xc3\xbf".to_vec()), ("x-empty".to_owned(), Vec::new()), - ], - 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, @@ -4005,6 +4044,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, @@ -4089,6 +4129,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, @@ -4141,6 +4182,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, @@ -4196,6 +4238,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, @@ -4558,6 +4601,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,