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::