Files
moli/moli-core/tests/fetch/security.rs

822 lines
32 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-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(())
}