Files
moli/moli-core/tests/fetch/security.rs
T
ldm0 8c770e9eb8 fix(fetch): preserve service worker response types and CORS headers
Carry the original response filter and validation state through Window
streams and Worker completions, including paused responses. Synthesized
responses remain basic, and already fetched bodies are not filtered by ORB
again. Keep client CORP/COEP checks against actual network response URLs.

Retain internal headers separately from the CORS public header view through
clone, deferred Cache.put, persistent Cache storage and respondWith. Match
Vary using the public header view and keep filtered cached headers immutable.
2026-09-23 09:04:46 +08:00

895 lines
35 KiB
Rust

use anyhow::Result;
use moli_core::runtime::{Browser, BrowserConfig};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::sync::Arc;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
task::{JoinHandle, JoinSet},
};
use url::Url;
#[derive(Debug)]
struct IncomingRequest {
method: String,
authority: String,
path: String,
origin: Option<String>,
cookie: Option<String>,
}
struct SecurityServers {
origin: String,
cross: String,
requests: Arc<Mutex<Vec<IncomingRequest>>>,
tasks: Vec<JoinHandle<()>>,
}
impl Drop for SecurityServers {
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}
impl SecurityServers {
async fn spawn() -> Result<Self> {
let main = TcpListener::bind("127.0.0.1:0").await?;
let other = TcpListener::bind("127.0.0.1:0").await?;
let origin = format!("http://{}", main.local_addr()?);
let cross = format!("http://{}", other.local_addr()?);
let requests = Arc::new(Mutex::new(Vec::new()));
let tasks = [main, other].into_iter().map(|listener| {
let requests = Arc::clone(&requests);
tokio::spawn(async move {
let mut connections = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((mut stream, _)) = accepted else { break };
let requests = Arc::clone(&requests);
connections.spawn(async move {
let mut bytes = Vec::new();
while !bytes.ends_with(b"\r\n\r\n") && bytes.len() < 16384 {
let Ok(byte) = stream.read_u8().await else { return };
bytes.push(byte);
}
let text = String::from_utf8_lossy(&bytes);
let mut parts = text.split_whitespace();
let method = parts.next().unwrap_or("").to_owned();
let path = parts.next().unwrap_or("/").to_owned();
let origin = text.lines().filter_map(|line| line.split_once(':'))
.find(|(key, _)| key.eq_ignore_ascii_case("Origin"))
.map(|(_, value)| value.trim().to_owned());
let cookie = text.lines().filter_map(|line| line.split_once(':'))
.find(|(key, _)| key.eq_ignore_ascii_case("Cookie"))
.map(|(_, value)| value.trim().to_owned());
let authority = text.lines().filter_map(|line| line.split_once(':'))
.find(|(key, _)| key.eq_ignore_ascii_case("Host")).unwrap().1.trim().to_owned();
let request = IncomingRequest { method, authority, path, origin, cookie };
let (status, headers, body) = fixture_response(&request);
requests.lock().push(request);
let response = format!("HTTP/1.1 {status} Fixture\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = stream.write_all(response.as_bytes()).await;
});
}
_ = connections.join_next(), if !connections.is_empty() => {}
}
}
})
}).collect();
Ok(Self {
origin,
cross,
requests,
tasks,
})
}
}
fn fixture_response(request: &IncomingRequest) -> (u16, String, String) {
let url = Url::parse(&format!("http://fixture.test{}", request.path)).unwrap();
let cors = format!(
"Access-Control-Allow-Origin: {}\r\nAccess-Control-Allow-Credentials: true\r\nAccess-Control-Allow-Methods: PUT\r\nAccess-Control-Allow-Headers: x-probe\r\n",
request.origin.as_deref().unwrap_or("*")
);
let cache = if url.path().starts_with("/cached-") {
"Cache-Control: max-age=600\r\n"
} else {
"Cache-Control: no-store\r\n"
};
if url.path() == "/page" {
return (
200,
"Content-Type: text/html\r\nSet-Cookie: originSession=present; Path=/; SameSite=Lax\r\n".to_owned(),
"<!doctype html><body>fetch security</body>".to_owned(),
);
}
if url.path() == "/blob-creator" {
return (
200,
"Content-Type: text/html\r\n".to_owned(),
r#"<!doctype html><script>
const blob = URL.createObjectURL(new Blob(['foreign']));
fetch(blob, {mode: 'same-origin'}).then(response => response.text()).then(text =>
parent.postMessage({blob, text}, '*'));
</script>"#
.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#"<!doctype html><script>
(async () => {
const results = [];
for (const mode of ['cors', 'no-cors']) {
const response = await fetch('/fetch-filter-response', {mode});
results.push({mode, type: response.type, status: response.status,
visible: response.headers.get('x-visible'),
private: response.headers.get('x-private'), body: await response.text()});
}
parent.postMessage(JSON.stringify(results), '*');
})().catch(error => parent.postMessage(JSON.stringify({error: String(error)}), '*'));
</script>"#
.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,
"Content-Type: text/html\r\nContent-Security-Policy: sandbox allow-scripts\r\n".to_owned(),
r#"<!doctype html>
<script>
let scriptDone = false;
let preloadDone = false;
function complete() {
if (scriptDone && preloadDone)
parent.postMessage('loaded:' + String(globalThis.loadedByOriginFixture === true), '*');
}
</script>
<link rel="modulepreload" crossorigin="anonymous" href="/subresource-origin/opaque/preload-allowed"
onload="preloadDone = true; complete()"
onerror="parent.postMessage('preload-error', '*')">
<script defer crossorigin="anonymous" src="/subresource-origin/opaque/classic-allowed"
onload="scriptDone = true; complete()"
onerror="parent.postMessage('error', '*')"></script>"#.to_owned(),
);
}
if url.path().starts_with("/xhr-origin/") {
let headers = if url.path().ends_with("-allowed") {
request
.origin
.as_ref()
.map(|origin| format!("Access-Control-Allow-Origin: {origin}\r\n"))
.unwrap_or_default()
} else if url.path().ends_with("-wrong") {
"Access-Control-Allow-Origin: http://wrong.test\r\n".to_owned()
} else {
String::new()
};
return (
200,
format!("{headers}Content-Type: text/plain\r\nCache-Control: no-store\r\n"),
"ok".to_owned(),
);
}
if url.path().starts_with("/subresource-origin/") {
let cors = if url.path().ends_with("-allowed") {
request
.origin
.as_ref()
.map(|origin| format!("Access-Control-Allow-Origin: {origin}\r\n"))
.unwrap_or_default()
} else {
String::new()
};
let name = url.path().rsplit('/').next().unwrap();
let (mime, body) = if name.starts_with("style-") {
("text/css", "body { color: green; }")
} else if name.starts_with("image-") {
(
"image/svg+xml",
r#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>"#,
)
} else if name.starts_with("track-") {
(
"text/vtt",
"WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nhello\n",
)
} else if name.starts_with("eventsource-") {
("text/event-stream", "data: load\n\n")
} else {
(
"text/javascript",
"globalThis.loadedByOriginFixture = true;",
)
};
return (
200,
format!("{cors}Content-Type: {mime}\r\nCache-Control: no-store\r\n"),
body.to_owned(),
);
}
if url.path() == "/sw.js" {
return (
200,
"Content-Type: text/javascript\r\nCache-Control: no-store\r\n".to_owned(),
r#"
const seen = [];
self.addEventListener('install', event => event.waitUntil(self.skipWaiting()));
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname === '/sw-log') {
event.respondWith(new Response(JSON.stringify(seen)));
} else if (url.pathname.startsWith('/sw-')) {
seen.push(url.pathname);
event.respondWith(url.pathname === '/sw-redirect'
? Response.redirect(url.searchParams.get('to'))
: new Response('worker'));
}
});
"#
.to_owned(),
);
}
if request.method == "OPTIONS" {
if let Some(status) = url.path().strip_prefix("/preflight-redirect/") {
return (
status.parse().unwrap(),
format!("{cors}Location: /preflight-target/{status}\r\n"),
String::new(),
);
}
return (204, cors, String::new());
}
if matches!(
url.path(),
"/redirect-allowed" | "/redirect-denied" | "/cached-redirect"
) {
let target = url.query_pairs().find(|(name, _)| name == "to").unwrap().1;
let cors = if url.path() == "/redirect-denied" {
String::new()
} else {
cors
};
return (
302,
format!("{cors}{cache}Location: {target}\r\n"),
String::new(),
);
}
if url.path() == "/track.vtt" {
return (
200,
format!("{cors}{cache}Content-Type: text/vtt\r\n"),
"WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nhello\n".to_owned(),
);
}
(
200,
format!("{cors}{cache}Content-Type: text/plain\r\n"),
"ok".to_owned(),
)
}
fn redirect(origin: &str, path: &str, target: &str) -> String {
let mut url = Url::parse(&format!("{origin}/{path}")).unwrap();
url.query_pairs_mut().append_pair("to", target);
url.into()
}
fn fetch_cases(cases: &Value) -> String {
format!(
r#"(async () => {{
const results = {{}};
for (const test of {cases}) {{
try {{
const response = await fetch(test.url, test);
results[test.name] = response.type === 'opaqueredirect'
? response.type : await response.text();
}} catch (error) {{ results[test.name] = error.name; }}
}}
return JSON.stringify(results);
}})()"#
)
}
fn results(value: Value) -> Result<Value> {
Ok(serde_json::from_str(
value["value"].as_str().expect("JSON result"),
)?)
}
#[tokio::test(flavor = "multi_thread")]
async fn same_origin_fetch_rejects_before_network_or_preflight() -> Result<()> {
let server = SecurityServers::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let cross_target = format!("{}/forbidden-target", server.cross);
let cross_redirect = redirect(&server.origin, "redirect-allowed", &cross_target);
let cases = json!([
{"name":"same", "url":"/resource", "mode":"same-origin"},
{"name":"same-put", "url":"/resource-put", "mode":"same-origin", "method":"PUT", "headers":{"X-Probe":"yes"}},
{"name":"cross", "url":format!("{}/forbidden-get", server.cross), "mode":"same-origin"},
{"name":"cross-put", "url":format!("{}/forbidden-put", server.cross), "mode":"same-origin", "method":"PUT", "headers":{"X-Probe":"yes"}},
{"name":"cors", "url":format!("{}/cors-control", server.cross), "mode":"cors"},
{"name":"data", "url":"data:text/plain,ok", "mode":"same-origin"},
{"name":"same-redirect", "url":redirect(&server.origin, "redirect-allowed", &format!("{}/local-final", server.origin)), "mode":"same-origin"},
{"name":"cross-redirect", "url":cross_redirect, "mode":"same-origin"},
{"name":"manual", "url":cross_redirect, "mode":"same-origin", "redirect":"manual"},
{"name":"error", "url":cross_redirect, "mode":"same-origin", "redirect":"error"}
]);
let observed = results(
page.evaluate_runtime_expression_with_await_async(&fetch_cases(&cases), true)
.await?,
)?;
assert_eq!(
observed,
json!({"same":"ok", "same-put":"ok", "cross":"TypeError", "cross-put":"TypeError", "cors":"ok", "data":"ok", "same-redirect":"ok", "cross-redirect":"TypeError", "manual":"opaqueredirect", "error":"TypeError"})
);
let requests = server.requests.lock();
assert!(
!requests
.iter()
.any(|request| request.path.starts_with("/forbidden-") || request.method == "OPTIONS"),
"{requests:?}"
);
assert!(
requests
.iter()
.any(|request| request.path == "/resource-put" && request.method == "PUT")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn cors_and_preflight_rejection_never_issue_redirect_target_requests() -> Result<()> {
let server = SecurityServers::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let forbidden = format!("{}/forbidden-cors-target", server.origin);
let mut cases = json!([
{"name":"denied", "url":redirect(&server.cross, "redirect-denied", &forbidden), "mode":"cors"},
{"name":"denied-include", "url":redirect(&server.cross, "redirect-denied", &forbidden), "mode":"cors", "credentials":"include"},
{"name":"allowed", "url":redirect(&server.cross, "redirect-allowed", &format!("{}/allowed-final", server.origin)), "mode":"cors", "credentials":"include"},
{"name":"preflight-ok", "url":format!("{}/preflight-ok", server.cross), "method":"PUT", "headers":{"X-Probe":"yes"}}
]);
for status in [301, 302, 303, 307, 308] {
cases.as_array_mut().unwrap().push(json!({"name":format!("preflight-{status}"), "url":format!("{}/preflight-redirect/{status}", server.cross), "method":"PUT", "headers":{"X-Probe":"yes"}}));
}
let observed = results(
page.evaluate_runtime_expression_with_await_async(&fetch_cases(&cases), true)
.await?,
)?;
for test in cases.as_array().unwrap() {
let name = test["name"].as_str().unwrap();
assert_eq!(
observed[name],
if matches!(name, "allowed" | "preflight-ok") {
"ok"
} else {
"TypeError"
},
"{name}: {observed}"
);
}
let requests = server.requests.lock();
assert!(
!requests
.iter()
.any(|request| request.path.starts_with("/forbidden-")
|| request.path.starts_with("/preflight-target/")),
"{requests:?}"
);
let preflights: Vec<_> = requests
.iter()
.filter(|request| request.path.starts_with("/preflight-redirect/"))
.collect();
assert_eq!(preflights.len(), 5, "{requests:?}");
assert!(preflights.iter().all(|request| request.method == "OPTIONS"));
assert_eq!(
requests
.iter()
.find(|request| request.path == "/allowed-final")
.unwrap()
.origin
.as_deref(),
Some("null")
);
assert!(
requests
.iter()
.any(|request| request.path == "/preflight-ok" && request.method == "PUT")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn same_origin_fetch_cannot_reuse_a_cross_origin_memory_cache_entry() -> Result<()> {
let server = SecurityServers::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let url = format!("{}/cached-resource", server.cross);
let cases = json!([
{"name":"warm", "url":url, "mode":"cors"},
{"name":"cached", "url":url, "mode":"cors"},
{"name":"same-origin", "url":url, "mode":"same-origin"}
]);
let observed = results(
page.evaluate_runtime_expression_with_await_async(&fetch_cases(&cases), true)
.await?,
)?;
assert_eq!(
observed,
json!({"warm":"ok", "cached":"ok", "same-origin":"TypeError"})
);
let requests = server.requests.lock();
assert_eq!(
requests
.iter()
.filter(|request| request.path.starts_with("/cached-"))
.count(),
1,
"{requests:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn same_origin_mode_rejects_before_service_worker_dispatch_and_redispatch() -> Result<()> {
let server = SecurityServers::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
page.evaluate_runtime_expression_with_await_async(r#"(async () => {
await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, {once:true}));
}
return true;
})()"#, true).await?;
let cases = json!([
{"name":"safe", "url":"/sw-safe", "mode":"same-origin"},
{"name":"cross", "url":format!("{}/sw-cross", server.cross), "mode":"same-origin"},
{"name":"redirect", "url":redirect(&server.origin, "sw-redirect", &format!("{}/sw-forbidden-target", server.cross)), "mode":"same-origin"},
{"name":"cors", "url":redirect(&server.cross, "sw-redirect", &format!("{}/sw-cors-target", server.origin)), "mode":"cors"},
{"name":"fallback", "url":redirect(&server.origin, "sw-redirect", &format!("{}/resource", server.origin)), "mode":"same-origin"},
{"name":"preflight", "url":redirect(&server.origin, "sw-redirect", &format!("{}/preflight-redirect/307", server.cross)), "method":"PUT", "headers":{"X-Probe":"yes"}}
]);
let observed = results(
page.evaluate_runtime_expression_with_await_async(&fetch_cases(&cases), true)
.await?,
)?;
assert_eq!(
observed,
json!({"safe":"worker", "cross":"TypeError", "redirect":"TypeError", "cors":"worker", "fallback":"ok", "preflight":"TypeError"})
);
let seen = results(
page.evaluate_runtime_expression_with_await_async(
"fetch('/sw-log').then(response => response.text())",
true,
)
.await?,
)?;
assert_eq!(
seen,
json!([
"/sw-safe",
"/sw-redirect",
"/sw-redirect",
"/sw-cors-target",
"/sw-redirect",
"/sw-redirect"
])
);
let requests = server.requests.lock();
assert!(
!requests
.iter()
.any(|request| request.path.starts_with("/sw-cross")
|| request.path.starts_with("/sw-forbidden-target")
|| request.path.starts_with("/preflight-target/")),
"{requests:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn text_track_without_crossorigin_blocks_cross_origin_before_network() -> Result<()> {
let server = SecurityServers::spawn().await?;
let mut config = BrowserConfig::default();
config.set_optional_resource_fetch_enabled(
moli_page_types::SubresourceResourceType::TextTrack,
true,
);
let browser = Browser::new(config)?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let cases = json!([
{"name":"same", "url":"/track.vtt?same"},
{"name":"cross", "url":format!("{}/track.vtt?cross", server.cross)},
{"name":"cors", "url":format!("{}/track.vtt?cors", server.cross), "crossOrigin":"anonymous"},
{"name":"redirect", "url":redirect(&server.origin, "redirect-allowed", &format!("{}/track.vtt?forbidden-redirect", server.cross))}
]);
let observed = results(
page.evaluate_runtime_expression_with_await_async(
&format!(
r#"(async () => {{
const results = {{}};
for (const test of {cases}) {{
results[test.name] = await new Promise(resolve => {{
const media = document.createElement('video');
if ('crossOrigin' in test) media.crossOrigin = test.crossOrigin;
const track = document.createElement('track');
track.onload = () => resolve('load');
track.onerror = () => resolve('error');
track.src = test.url;
media.append(track);
document.body.append(media);
track.track.mode = 'hidden';
}});
}}
return JSON.stringify(results);
}})()"#
),
true,
)
.await?,
)?;
assert_eq!(
observed,
json!({"same":"load", "cross":"error", "cors":"load", "redirect":"error"})
);
let requests = server.requests.lock();
assert!(
!requests.iter().any(|request| matches!(
request.path.as_str(),
"/track.vtt?cross" | "/track.vtt?forbidden-redirect"
)),
"{requests:?}"
);
assert!(
requests
.iter()
.any(|request| request.path == "/track.vtt?cors")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn window_and_worker_same_origin_mode_checks_blob_before_local_resolution() -> Result<()> {
let server = SecurityServers::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let expression = format!(
"({})({})",
include_str!("fixtures/blob-origin.js"),
json!(server.cross)
);
let observed = results(
page.evaluate_runtime_expression_with_await_async(&expression, true)
.await?,
)?;
assert_eq!(
observed,
json!({
"creator": "foreign",
"window-foreign": "TypeError", "window-local": "local", "window-data": "data",
"worker-foreign": "TypeError", "worker-local": "local", "worker-data": "data"
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn srcdoc_fetch_uses_inherited_or_opaque_origin_independently_of_base_url() -> Result<()> {
let server = SecurityServers::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let expression = format!(
"({})({})",
include_str!("fixtures/srcdoc-origin.js"),
json!(server.cross)
);
let observed = results(
page.evaluate_runtime_expression_with_await_async(&expression, true)
.await?,
)?;
assert_eq!(
observed,
json!({
"inherited": {"relative":"TypeError", "home":"ok", "redirect":"TypeError", "blob":"local", "data":"data", "cors":"ok", "preflight":"ok"},
"opaque": {"relative":"TypeError", "home":"TypeError", "redirect":"TypeError", "blob":"TypeError", "data":"data", "cors":"ok", "preflight":"ok"},
"topBase":"TypeError"
})
);
let requests = server.requests.lock();
assert!(
!requests
.iter()
.any(|request| request.path.starts_with("/forbidden-")),
"{requests:?}"
);
let home = requests
.iter()
.find(|request| request.path == "/allowed-home")
.expect("same-origin control");
assert_eq!(
home.cookie.as_deref(),
Some("originSession=present"),
"{home:?}"
);
for (kind, expected_origin) in [("inherited", server.origin.as_str()), ("opaque", "null")] {
for (method, path) in [
("GET", format!("/cors-{kind}")),
("OPTIONS", format!("/preflight-{kind}")),
("PUT", format!("/preflight-{kind}")),
] {
let request = requests
.iter()
.find(|request| request.method == method && request.path == path)
.expect("expected allowed request");
assert_eq!(
request.origin.as_deref(),
Some(expected_origin),
"{request:?}"
);
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn srcdoc_xhr_uses_committed_origin_for_sync_and_async_cors() -> Result<()> {
let server = SecurityServers::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let expression = format!(
"({})({})",
include_str!("fixtures/xhr-origin.js"),
json!(server.cross)
);
let observed = results(
page.evaluate_runtime_expression_with_await_async(&expression, true)
.await?,
)?;
for kind in ["inherited", "opaque"] {
for mode in ["async", "sync"] {
for policy in ["denied", "allowed", "wrong", "home"] {
let name = format!("{mode}-{policy}");
let allowed = policy == "allowed" || (policy == "home" && kind == "inherited");
assert_eq!(
observed[kind][&name],
if allowed { "load:ok" } else { "error" },
"{kind}/{name}: {observed}"
);
let path = format!("/xhr-origin/{kind}/{name}");
let requests = server.requests.lock();
let request = requests
.iter()
.find(|request| request.path == path)
.expect("XHR must reach its target for a CORS response check");
let expected_origin = if kind == "opaque" {
Some("null")
} else if policy == "home" {
None
} else {
Some(server.origin.as_str())
};
assert_eq!(request.origin.as_deref(), expected_origin, "{request:?}");
let expected_cookie =
(kind == "inherited" && policy == "home").then_some("originSession=present");
assert_eq!(request.cookie.as_deref(), expected_cookie, "{request:?}");
}
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn srcdoc_subresources_share_committed_origin_and_track_mode_gate() -> Result<()> {
let server = SecurityServers::spawn().await?;
let mut config = BrowserConfig::default();
for resource in [
moli_page_types::SubresourceResourceType::Image,
moli_page_types::SubresourceResourceType::TextTrack,
] {
config.set_optional_resource_fetch_enabled(resource, true);
}
let browser = Browser::new(config)?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let expression = format!(
"({})({})",
include_str!("fixtures/subresource-origin.js"),
json!(server.cross)
);
let observed = results(
tokio::time::timeout(
std::time::Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(&expression, true),
)
.await
.unwrap_or_else(|_| {
panic!(
"subresource origin fixture stalled: {:?}",
server.requests.lock()
)
})?,
)?;
let requests = server.requests.lock();
for kind in ["inherited", "opaque"] {
for resource in [
"classic",
"module",
"style",
"image",
"eventsource",
"track",
] {
for policy in ["denied", "allowed"] {
let name = format!("{resource}-{policy}");
assert_eq!(
observed[kind][&name],
if policy == "allowed" { "load" } else { "error" },
"{kind}/{name}: {observed}; {requests:?}"
);
let path = format!(
"/subresource-origin/{}/{name}",
if name == "style-allowed" {
"shared"
} else {
kind
}
);
let request = requests
.iter()
.find(|request| {
request.path == path
&& request.origin.as_deref()
== Some(if kind == "opaque" {
"null"
} else {
&server.origin
})
})
.unwrap_or_else(|| {
panic!("subresource request from {kind} reached server: {requests:?}")
});
assert_eq!(
request.origin.as_deref(),
Some(if kind == "opaque" {
"null"
} else {
&server.origin
}),
"{request:?}"
);
assert_eq!(
request.authority,
server.cross.trim_start_matches("http://"),
"{request:?}"
);
assert!(request.cookie.is_none(), "{request:?}");
}
}
assert_eq!(
observed[kind]["track-home"],
if kind == "inherited" { "load" } else { "error" }
);
assert_eq!(observed[kind]["track-cross"], "error");
assert!(
!requests
.iter()
.any(|request| request.path == format!("/subresource-origin/{kind}/track-cross"))
);
}
assert!(
!requests
.iter()
.any(|request| request.path == "/subresource-origin/opaque/track-home")
);
assert!(
requests
.iter()
.any(|request| request.path == "/subresource-origin/inherited/track-home")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn csp_sandboxed_child_script_and_modulepreload_preserve_opaque_origin() -> Result<()> {
let server = SecurityServers::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut page = browser.fetch(&format!("{}/page", server.origin)).await?;
let observed = 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-preload';
document.body.append(frame);
})"#,
true,
)
.await?;
assert_eq!(observed["value"], "loaded:true", "{observed}");
let requests = server.requests.lock();
for name in ["classic-allowed", "preload-allowed"] {
let path = format!("/subresource-origin/opaque/{name}");
let matching = requests
.iter()
.filter(|request| request.path == path)
.collect::<Vec<_>>();
assert!(!matching.is_empty(), "missing {name}: {requests:?}");
for request in matching {
assert_eq!(request.origin.as_deref(), Some("null"), "{request:?}");
assert!(request.cookie.is_none(), "{request:?}");
}
}
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::<Vec<_>>();
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(())
}