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,