mirror of
https://github.com/lexmount/moli.git
synced 2026-11-13 16:10:01 +00:00
912 lines
36 KiB
Rust
912 lines
36 KiB
Rust
use super::*;
|
|
|
|
const MISMATCHED_MODULE_INTEGRITY: &str =
|
|
"sha384-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn parser_modulepreload_csp_hash_admission_preserves_integrity_metadata() -> Result<()> {
|
|
let servers = IntegrityServers::spawn().await?;
|
|
let browser = Browser::new(AppConfig::default())?;
|
|
for case in ["match", "mismatch", "missing", "tampered"] {
|
|
let label = format!("parser-{case}");
|
|
let mut page = browser
|
|
.fetch(&format!(
|
|
"{}/module-csp-hash-preload.html?{label}",
|
|
servers.origin
|
|
))
|
|
.await?;
|
|
let source = format!(
|
|
"/{}?{label}",
|
|
if case == "tampered" {
|
|
"module-csp-hash-tampered.js"
|
|
} else {
|
|
"script.js"
|
|
}
|
|
);
|
|
let result = page
|
|
.evaluate_runtime_expression_with_await_async(
|
|
r#"
|
|
new Promise(resolve => {
|
|
globalThis.finishPreload = () => resolve(JSON.stringify({
|
|
events: globalThis.hashPreloadEvents,
|
|
executions: globalThis.sriExecutions || 0,
|
|
}));
|
|
if (globalThis.hashPreloadEvents?.length) finishPreload();
|
|
})
|
|
"#,
|
|
true,
|
|
)
|
|
.await
|
|
.map_err(|error| anyhow::anyhow!("{label}: {error}"))?;
|
|
let result: serde_json::Value = serde_json::from_str(result["value"].as_str().unwrap())?;
|
|
assert_eq!(
|
|
result,
|
|
serde_json::json!({
|
|
"events": [if case == "match" { "load" } else { "error" }], "executions": 0,
|
|
}),
|
|
"{label}"
|
|
);
|
|
assert_eq!(
|
|
servers
|
|
.requests
|
|
.lock()
|
|
.iter()
|
|
.filter(|request| request.path == source)
|
|
.count(),
|
|
usize::from(matches!(case, "match" | "tampered")),
|
|
"{label}: CSP failures must stop before fetch; SRI still checks admitted response bytes"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn module_csp_redirect_completion_uses_admitted_snapshot_after_meta_policy_change()
|
|
-> Result<()> {
|
|
let origin = TcpListener::bind("127.0.0.1:0").await?;
|
|
let target = TcpListener::bind("127.0.0.1:0").await?;
|
|
let origin_url = format!("http://{}", origin.local_addr()?);
|
|
let target_url = format!("http://{}/final.txt", target.local_addr()?);
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let origin_requests = Arc::clone(&requests);
|
|
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
|
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
|
let mut servers = JoinSet::new();
|
|
servers.spawn(async move {
|
|
loop {
|
|
let Ok((mut stream, _)) = origin.accept().await else { break };
|
|
let mut head = Vec::new();
|
|
while !head.ends_with(b"\r\n\r\n") { head.push(stream.read_u8().await?); }
|
|
let head = String::from_utf8(head)?;
|
|
let path = head.split_whitespace().nth(1).unwrap();
|
|
origin_requests.lock().push(path.to_owned());
|
|
let (status, headers, body) = match path {
|
|
"/page.html" => ("200 OK", "Content-Type: text/html\r\n".to_owned(), "<!doctype html><head></head><body>snapshot"),
|
|
"/before.txt" => ("302 Found", format!("Location: {target_url}\r\n"), ""),
|
|
_ => ("200 OK", "Content-Type: text/plain\r\n".to_owned(), "must not be fetched"),
|
|
};
|
|
stream.write_all(format!("HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await?;
|
|
}
|
|
anyhow::Ok(())
|
|
});
|
|
servers.spawn(async move {
|
|
let (mut stream, _) = target.accept().await?;
|
|
let mut head = Vec::new();
|
|
while !head.ends_with(b"\r\n\r\n") { head.push(stream.read_u8().await?); }
|
|
started_tx.send(()).unwrap();
|
|
release_rx.await?;
|
|
let body = "admitted snapshot";
|
|
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await?;
|
|
anyhow::Ok(())
|
|
});
|
|
let browser = Browser::new(AppConfig::default())?;
|
|
let mut page = browser.fetch(&format!("{origin_url}/page.html")).await?;
|
|
page.evaluate_runtime_expression_async(
|
|
r#"
|
|
globalThis.moduleViolations = [];
|
|
addEventListener('securitypolicyviolation', event => moduleViolations.push(event.blockedURI));
|
|
globalThis.admittedImport = import('/before.txt', {with:{type:'text'}})
|
|
.then(module => module.default, error => 'error: ' + error.message);
|
|
'started';
|
|
"#,
|
|
)
|
|
.await?;
|
|
tokio::time::timeout(Duration::from_secs(15), started_rx).await??;
|
|
page.evaluate_runtime_expression_async(
|
|
r#"
|
|
const meta = document.createElement('meta');
|
|
meta.httpEquiv = 'Content-Security-Policy'; meta.content = "connect-src 'none'";
|
|
document.head.append(meta); 'policy installed';
|
|
"#,
|
|
)
|
|
.await?;
|
|
release_tx.send(()).unwrap();
|
|
let admitted = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
page.evaluate_runtime_expression_with_await_async("admittedImport", true),
|
|
)
|
|
.await??;
|
|
assert_eq!(admitted["value"], "admitted snapshot", "{admitted}");
|
|
let rejected = page
|
|
.evaluate_runtime_expression_with_await_async(
|
|
r#"
|
|
import('/after.txt', {with:{type:'text'}}).then(() => 'loaded', error => error.name)
|
|
"#,
|
|
true,
|
|
)
|
|
.await?;
|
|
assert_eq!(rejected["value"], "TypeError", "{rejected}");
|
|
assert_eq!(*requests.lock(), ["/page.html", "/before.txt"]);
|
|
while let Some(task) = servers.try_join_next() {
|
|
task??;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) fn fixture_response(
|
|
path: &str,
|
|
query: &str,
|
|
origin: &str,
|
|
cross: &str,
|
|
) -> (&'static str, String, String) {
|
|
if path == "/module-csp-hash-preload.html" {
|
|
let case = query.split_once('-').unwrap().1;
|
|
let integrity = match case {
|
|
"missing" => String::new(),
|
|
"mismatch" => format!(" integrity=\"{MISMATCHED_MODULE_INTEGRITY}\""),
|
|
_ => format!(" integrity=\"{INTEGRITY}\""),
|
|
};
|
|
let script = if case == "tampered" {
|
|
"module-csp-hash-tampered.js"
|
|
} else {
|
|
"script.js"
|
|
};
|
|
let link = format!(
|
|
r#"<link rel="modulepreload" href="/{script}?{query}"{integrity}
|
|
onload="(globalThis.hashPreloadEvents ||= []).push(event.type); globalThis.finishPreload?.()"
|
|
onerror="(globalThis.hashPreloadEvents ||= []).push(event.type); globalThis.finishPreload?.()">"#
|
|
);
|
|
return (
|
|
"200 OK",
|
|
format!(
|
|
"Content-Type: text/html\r\nContent-Security-Policy: script-src '{INTEGRITY}'; script-src-attr 'unsafe-inline'\r\n"
|
|
),
|
|
format!(
|
|
"<!doctype html><html><head>{link}</head><body>Hash-authorized modulepreload</body></html>"
|
|
),
|
|
);
|
|
}
|
|
if path == "/module-csp-hash-tampered.js" {
|
|
return (
|
|
"200 OK",
|
|
"Content-Type: text/javascript\r\n".to_owned(),
|
|
"globalThis.sriExecutions = 1000;".to_owned(),
|
|
);
|
|
}
|
|
if matches!(path, "/module-csp-report-uri" | "/module-csp-report-to") {
|
|
return ("204 No Content", String::new(), String::new());
|
|
}
|
|
if path == "/module-csp-privacy.html" {
|
|
let child = query.starts_with("child-");
|
|
let report_only = query.contains("report-only");
|
|
let reporting = if query.ends_with("report-to") {
|
|
"report-to csp"
|
|
} else {
|
|
"report-uri /module-csp-report-uri"
|
|
};
|
|
let restrictive = format!(
|
|
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; connect-src 'self'; style-src-elem 'self'; {reporting}"
|
|
);
|
|
let enforce = if report_only {
|
|
"script-src * 'unsafe-inline' 'wasm-unsafe-eval'; connect-src *; style-src-elem *"
|
|
} else {
|
|
&restrictive
|
|
};
|
|
let report = if report_only {
|
|
format!("Content-Security-Policy-Report-Only: {restrictive}\r\n")
|
|
} else {
|
|
String::new()
|
|
};
|
|
let mut body = r#"<!doctype html><script>
|
|
globalThis.privacyProbe = async () => {
|
|
const modules = [['text','text'],['json','json'],['css','css'],['js',null]];
|
|
/*WASM*/
|
|
const events = [];
|
|
let signal;
|
|
const reported = new Promise(resolve => signal = resolve);
|
|
addEventListener('securitypolicyviolation', event => {
|
|
events.push({blockedURI:event.blockedURI, disposition:event.disposition, directive:event.effectiveDirective});
|
|
if (events.length === modules.length) signal();
|
|
});
|
|
const results = [];
|
|
for (const [suffix, type] of modules) {
|
|
const url = '/module-csp.' + suffix + '?privacy-redirect';
|
|
try { await import(url, type ? {with:{type}} : {}); results.push({status:'loaded'}); }
|
|
catch (error) { results.push({status:error.name, message:error.message}); }
|
|
}
|
|
await reported;
|
|
return JSON.stringify({events, results});
|
|
};
|
|
</script><body>Module redirect privacy"#.to_owned();
|
|
// Child Wasm evaluation currently looks up records in the top-level
|
|
// modulator. Window and Worker tests retain Wasm coverage.
|
|
body = body.replace(
|
|
"/*WASM*/",
|
|
if child {
|
|
""
|
|
} else {
|
|
"modules.push(['wasm',null]);"
|
|
},
|
|
);
|
|
if child {
|
|
body.push_str(
|
|
"<script>privacyProbe().then(result => parent.postMessage(result, '*'));</script>",
|
|
);
|
|
}
|
|
return (
|
|
"200 OK",
|
|
format!(
|
|
"Content-Type: text/html\r\nContent-Security-Policy: {enforce}\r\n{report}Reporting-Endpoints: csp=\"{origin}/module-csp-report-to\"\r\n"
|
|
),
|
|
body,
|
|
);
|
|
}
|
|
if query == "privacy-redirect" {
|
|
return (
|
|
"302 Found",
|
|
format!(
|
|
"Location: {cross}{path}?token=SECRET-redirect\r\nAccess-Control-Allow-Origin: *\r\n"
|
|
),
|
|
String::new(),
|
|
);
|
|
}
|
|
if path == "/module-csp.html" {
|
|
let frame_case = query.strip_prefix("frame-");
|
|
let preload_case = query.strip_prefix("preload-");
|
|
let query = frame_case.or(preload_case).unwrap_or(query);
|
|
let policy = match query {
|
|
"connect-none" => "connect-src 'none'; style-src-elem 'self'",
|
|
"default-none" => "default-src 'none'; style-src-elem 'self'",
|
|
"style-none" => "connect-src 'self'; style-src-elem 'none'",
|
|
"redirect" => "connect-src 'self'; style-src-elem 'self'",
|
|
"redirect-report-only" | "roundtrip-report-only" => "connect-src *; style-src-elem *",
|
|
_ => "connect-src 'self'; style-src-elem 'self'",
|
|
};
|
|
let script_policy = if query.starts_with("script-") {
|
|
if query.ends_with("report-only") {
|
|
"script-src * 'unsafe-inline' 'wasm-unsafe-eval'"
|
|
} else {
|
|
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'"
|
|
}
|
|
} else {
|
|
"script-src 'nonce-setup'"
|
|
};
|
|
let report = if query.starts_with("script-") && query.ends_with("report-only") {
|
|
"Content-Security-Policy-Report-Only: script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'\r\n"
|
|
} else if query == "report-only" {
|
|
"Content-Security-Policy-Report-Only: connect-src 'none'; style-src-elem 'none'\r\n"
|
|
} else if query == "redirect-report-only" || query == "roundtrip-report-only" {
|
|
"Content-Security-Policy-Report-Only: connect-src 'self'; style-src-elem 'self'\r\n"
|
|
} else {
|
|
""
|
|
};
|
|
let mut response = (
|
|
"200 OK",
|
|
format!("Content-Type: text/html\r\nContent-Security-Policy: {script_policy}; {policy}\r\n{report}"),
|
|
r#"<!doctype html><script nonce="setup">
|
|
globalThis.moduleViolations = [];
|
|
document.addEventListener('securitypolicyviolation', event => {
|
|
moduleViolations.push([event.effectiveDirective, event.disposition, event.blockedURI]);
|
|
document.dispatchEvent(new Event('module-violation-observed'));
|
|
});
|
|
globalThis.preloadResults = {};
|
|
globalThis.preloadsReady = new Promise(resolve => {
|
|
const observe = event => {
|
|
if (event.target.tagName !== 'LINK') return;
|
|
preloadResults[event.target.as] = event.type;
|
|
if (Object.keys(preloadResults).length === 3) resolve(preloadResults);
|
|
};
|
|
document.addEventListener('load', observe, true);
|
|
document.addEventListener('error', observe, true);
|
|
});
|
|
</script>
|
|
<link rel="modulepreload" as="text" href="/module-csp.text?preload">
|
|
<link rel="modulepreload" as="json" href="/module-csp.json?preload">
|
|
<link rel="modulepreload" as="style" href="/module-csp.css?preload">
|
|
<body>Module CSP"#.to_owned() + if query == "allowed" {
|
|
r#"<script type="module" nonce="setup">
|
|
import text from '/module-csp.text?static' with {type: 'text'};
|
|
import json from '/module-csp.json?static' with {type: 'json'};
|
|
import css from '/module-csp.css?static' with {type: 'css'};
|
|
globalThis.staticModules = [text, json, css.cssRules.length];
|
|
</script>"#
|
|
} else { "" },
|
|
);
|
|
if preload_case.is_some() {
|
|
let target = if query == "redirect" {
|
|
"redirect"
|
|
} else {
|
|
"roundtrip"
|
|
};
|
|
response.2 = response.2.replace("?preload", &format!("?{target}"));
|
|
}
|
|
if frame_case.is_some() {
|
|
response.2.push_str(
|
|
r#"<script nonce="setup">
|
|
(async () => {
|
|
const events = [];
|
|
let signal;
|
|
const reported = new Promise(resolve => signal = resolve);
|
|
addEventListener('securitypolicyviolation', event => {
|
|
events.push([event.effectiveDirective, event.disposition, event.blockedURI]);
|
|
signal();
|
|
});
|
|
const status = await import('/module-csp.text?roundtrip', {with:{type:'text'}})
|
|
.then(() => 'loaded', error => error.name);
|
|
await reported;
|
|
parent.postMessage(JSON.stringify({status, events}), '*');
|
|
})();
|
|
</script>"#,
|
|
);
|
|
}
|
|
return response;
|
|
}
|
|
if query == "redirect" || query == "roundtrip" || query == "return" {
|
|
return (
|
|
"302 Found",
|
|
format!(
|
|
"Location: {}{path}?{}\r\nAccess-Control-Allow-Origin: *\r\n",
|
|
if query == "return" { origin } else { cross },
|
|
if query == "roundtrip" {
|
|
"return"
|
|
} else {
|
|
"final"
|
|
}
|
|
),
|
|
String::new(),
|
|
);
|
|
}
|
|
let (mime, body) = match path {
|
|
"/module-csp.text" => ("text/plain", "module text"),
|
|
"/module-csp.js" => ("text/javascript", "export default 'module script';"),
|
|
"/module-csp.wasm" => ("application/wasm", "\0asm\u{1}\0\0\0"),
|
|
"/module-csp.json" => ("application/json", "\"module text\""),
|
|
"/module-csp.css" => ("text/css", ".module-csp { color: green; }"),
|
|
_ => unreachable!("unknown module CSP fixture {path}"),
|
|
};
|
|
(
|
|
"200 OK",
|
|
format!("Content-Type: {mime}\r\nAccess-Control-Allow-Origin: *\r\n"),
|
|
body.to_owned(),
|
|
)
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn module_csp_redirect_privacy_events_reports_and_errors_use_original_request_url()
|
|
-> Result<()> {
|
|
for child in [false, true] {
|
|
for report_only in [false, true] {
|
|
for report_to in [false, true] {
|
|
let suffixes: &[&str] = if child {
|
|
&["text", "json", "css", "js"]
|
|
} else {
|
|
&["text", "json", "css", "js", "wasm"]
|
|
};
|
|
let module_count = suffixes.len();
|
|
let servers = IntegrityServers::spawn().await?;
|
|
let browser = Browser::new(AppConfig::default())?;
|
|
let case = format!(
|
|
"{}{}-{}",
|
|
if child { "child-" } else { "" },
|
|
if report_only {
|
|
"report-only"
|
|
} else {
|
|
"enforce"
|
|
},
|
|
if report_to { "report-to" } else { "report-uri" }
|
|
);
|
|
let page_url = if child {
|
|
format!("{}/page.html", servers.origin)
|
|
} else {
|
|
format!("{}/module-csp-privacy.html?{case}", servers.origin)
|
|
};
|
|
let mut page = browser.fetch(&page_url).await?;
|
|
let expression = if child {
|
|
format!(
|
|
r#"new Promise(resolve => {{
|
|
const frame = document.createElement('iframe');
|
|
addEventListener('message', function receive(event) {{
|
|
if (event.source !== frame.contentWindow) return;
|
|
removeEventListener('message', receive); resolve(event.data);
|
|
}});
|
|
frame.src = '/module-csp-privacy.html?{case}';
|
|
document.body.append(frame);
|
|
}})"#
|
|
)
|
|
} else {
|
|
"privacyProbe()".to_owned()
|
|
};
|
|
let result = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
page.evaluate_runtime_expression_with_await_async(&expression, true),
|
|
)
|
|
.await??;
|
|
let serialized = result["value"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("{case}: {result}"));
|
|
assert!(
|
|
!serialized.contains("SECRET-redirect"),
|
|
"{case}: {serialized}"
|
|
);
|
|
assert!(
|
|
!serialized.contains(&servers.cross_origin),
|
|
"{case}: {serialized}"
|
|
);
|
|
let result: serde_json::Value = serde_json::from_str(serialized)?;
|
|
assert_eq!(
|
|
result["events"].as_array().unwrap().len(),
|
|
module_count,
|
|
"{case}: {result}"
|
|
);
|
|
assert_eq!(
|
|
result["results"].as_array().unwrap().len(),
|
|
module_count,
|
|
"{case}: {result}"
|
|
);
|
|
for outcome in result["results"].as_array().unwrap() {
|
|
assert_eq!(
|
|
outcome["status"],
|
|
if report_only { "loaded" } else { "TypeError" },
|
|
"{case}: {result}"
|
|
);
|
|
}
|
|
let mut originals: Vec<_> = suffixes
|
|
.iter()
|
|
.map(|suffix| {
|
|
format!("{}/module-csp.{suffix}?privacy-redirect", servers.origin)
|
|
})
|
|
.collect();
|
|
originals.sort();
|
|
let mut event_urls: Vec<_> = result["events"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|event| {
|
|
assert_eq!(
|
|
event["disposition"],
|
|
if report_only { "report" } else { "enforce" }
|
|
);
|
|
event["blockedURI"].as_str().unwrap().to_owned()
|
|
})
|
|
.collect();
|
|
event_urls.sort();
|
|
assert_eq!(event_urls, originals, "{case}");
|
|
let endpoint = if report_to {
|
|
"/module-csp-report-to"
|
|
} else {
|
|
"/module-csp-report-uri"
|
|
};
|
|
tokio::time::timeout(Duration::from_secs(15), async {
|
|
loop {
|
|
let changed = servers.requests_changed.notified();
|
|
if servers
|
|
.requests
|
|
.lock()
|
|
.iter()
|
|
.filter(|r| r.path == endpoint)
|
|
.count()
|
|
>= module_count
|
|
{
|
|
break;
|
|
}
|
|
changed.await;
|
|
}
|
|
})
|
|
.await?;
|
|
let requests = servers.requests.lock();
|
|
assert_eq!(
|
|
requests
|
|
.iter()
|
|
.filter(|r| r.path.contains("token=SECRET-redirect"))
|
|
.count(),
|
|
if report_only { module_count } else { 0 },
|
|
"{case}: forbidden target request"
|
|
);
|
|
let reports: Vec<_> = requests.iter().filter(|r| r.path == endpoint).collect();
|
|
assert_eq!(
|
|
reports.len(),
|
|
module_count,
|
|
"{case}: reports must be sent once per module"
|
|
);
|
|
let mut report_urls = Vec::new();
|
|
for report in reports {
|
|
assert_eq!(report.method, "POST");
|
|
assert!(
|
|
!report.body.contains("SECRET-redirect"),
|
|
"{case}: {}",
|
|
report.body
|
|
);
|
|
assert!(
|
|
!report.body.contains(&servers.cross_origin),
|
|
"{case}: {}",
|
|
report.body
|
|
);
|
|
let body: serde_json::Value = serde_json::from_str(&report.body)?;
|
|
report_urls.push(
|
|
if report_to {
|
|
body[0]["body"]["blockedURL"].as_str().unwrap()
|
|
} else {
|
|
body["csp-report"]["blocked-uri"].as_str().unwrap()
|
|
}
|
|
.to_owned(),
|
|
);
|
|
}
|
|
report_urls.sort();
|
|
assert_eq!(report_urls, originals, "{case}");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn synthetic_modules_apply_destination_csp_to_imports_and_modulepreloads() -> Result<()> {
|
|
for case in [
|
|
"connect-none",
|
|
"default-none",
|
|
"style-none",
|
|
"allowed",
|
|
"report-only",
|
|
"redirect",
|
|
"redirect-report-only",
|
|
"roundtrip",
|
|
"roundtrip-report-only",
|
|
] {
|
|
let servers = IntegrityServers::spawn().await?;
|
|
let browser = Browser::new(AppConfig::default())?;
|
|
let mut page = browser
|
|
.fetch(&format!("{}/module-csp.html?{case}", servers.origin))
|
|
.await?;
|
|
let expression = format!(
|
|
r#"(async () => {{
|
|
const preloads = await preloadsReady;
|
|
const results = [];
|
|
for (const [type, suffix] of [['text', 'text'], ['json', 'json'], ['css', 'css']]) {{
|
|
results.push(await import('/module-csp.' + suffix + '?{query}', {{with: {{type}}}})
|
|
.then(module => type === 'css' ? module.default.cssRules.length : module.default,
|
|
error => error.name));
|
|
}}
|
|
if ({expected_violations} > 0) {{
|
|
const ready = () => moduleViolations.length >= {expected_violations};
|
|
if (!ready()) await new Promise(resolve => {{
|
|
const observe = () => {{ if (ready()) {{
|
|
document.removeEventListener('module-violation-observed', observe); resolve();
|
|
}} }};
|
|
document.addEventListener('module-violation-observed', observe);
|
|
}});
|
|
}}
|
|
return JSON.stringify({{preloads, results, staticModules: globalThis.staticModules,
|
|
violations: moduleViolations}});
|
|
}})()"#,
|
|
query = if case.starts_with("redirect") {
|
|
"redirect"
|
|
} else if case.starts_with("roundtrip") {
|
|
"roundtrip"
|
|
} else {
|
|
"dynamic"
|
|
},
|
|
expected_violations = match case {
|
|
"connect-none" | "default-none" => 4,
|
|
"style-none" => 2,
|
|
"report-only" => 6,
|
|
"redirect" | "redirect-report-only" | "roundtrip" | "roundtrip-report-only" => 3,
|
|
_ => 0,
|
|
},
|
|
);
|
|
let observed = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
page.evaluate_runtime_expression_with_await_async(&expression, true),
|
|
)
|
|
.await??;
|
|
let observed: serde_json::Value = serde_json::from_str(
|
|
observed["value"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("{case}: {observed}")),
|
|
)?;
|
|
let connect_blocked = matches!(case, "connect-none" | "default-none");
|
|
let style_blocked = case == "style-none";
|
|
assert_eq!(
|
|
observed["preloads"],
|
|
serde_json::json!({
|
|
"text": if connect_blocked { "error" } else { "load" },
|
|
"json": if connect_blocked { "error" } else { "load" },
|
|
"style": if style_blocked { "error" } else { "load" },
|
|
}),
|
|
"{case}: {observed}"
|
|
);
|
|
let redirected = case == "redirect" || case == "roundtrip";
|
|
assert_eq!(
|
|
observed["results"],
|
|
serde_json::json!([
|
|
if connect_blocked || redirected {
|
|
serde_json::json!("TypeError")
|
|
} else {
|
|
serde_json::json!("module text")
|
|
},
|
|
if connect_blocked || redirected {
|
|
serde_json::json!("TypeError")
|
|
} else {
|
|
serde_json::json!("module text")
|
|
},
|
|
if style_blocked || redirected {
|
|
serde_json::json!("TypeError")
|
|
} else {
|
|
serde_json::json!(1)
|
|
},
|
|
]),
|
|
"{case}: {observed}"
|
|
);
|
|
let requests = servers.requests.lock();
|
|
for extension in ["text", "json", "css"] {
|
|
let blocked = if extension == "css" {
|
|
style_blocked
|
|
} else {
|
|
connect_blocked
|
|
};
|
|
let count = requests
|
|
.iter()
|
|
.filter(|request| {
|
|
request
|
|
.path
|
|
.starts_with(&format!("/module-csp.{extension}"))
|
|
})
|
|
.count();
|
|
assert_eq!(
|
|
count,
|
|
if blocked {
|
|
0
|
|
} else if case == "roundtrip-report-only" {
|
|
4
|
|
} else if case == "redirect-report-only" || case == "allowed" {
|
|
3
|
|
} else {
|
|
2
|
|
},
|
|
"{case}/{extension}: {requests:?}"
|
|
);
|
|
}
|
|
if redirected {
|
|
assert!(
|
|
requests
|
|
.iter()
|
|
.all(|request| !request.path.ends_with("?final")
|
|
&& !request.path.ends_with("?return")),
|
|
"blocked redirect contacted its destination: {requests:?}"
|
|
);
|
|
}
|
|
let violations = observed["violations"].as_array().unwrap();
|
|
if case == "allowed" {
|
|
assert!(violations.is_empty(), "{observed}");
|
|
assert_eq!(
|
|
observed["staticModules"],
|
|
serde_json::json!(["module text", "module text", 1])
|
|
);
|
|
}
|
|
if case.ends_with("report-only") || redirected {
|
|
for directive in ["connect-src", "style-src-elem"] {
|
|
assert!(
|
|
violations.iter().any(|entry| entry[0] == directive
|
|
&& entry[1] == if redirected { "enforce" } else { "report" }),
|
|
"{case}: {observed}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn child_module_csp_redirects_keep_the_child_policy_and_reporting_owner() -> Result<()> {
|
|
for report_only in [false, true] {
|
|
let servers = IntegrityServers::spawn().await?;
|
|
let browser = Browser::new(AppConfig::default())?;
|
|
let mut page = browser
|
|
.fetch(&format!("{}/page.html", servers.origin))
|
|
.await?;
|
|
let case = if report_only {
|
|
"roundtrip-report-only"
|
|
} else {
|
|
"roundtrip"
|
|
};
|
|
let expression = format!(
|
|
r#"new Promise(resolve => {{
|
|
const frame = document.createElement('iframe');
|
|
addEventListener('message', function receive(event) {{
|
|
if (event.source !== frame.contentWindow) return;
|
|
removeEventListener('message', receive);
|
|
resolve(event.data);
|
|
}});
|
|
frame.src = '/module-csp.html?frame-{case}';
|
|
document.body.append(frame);
|
|
}})"#
|
|
);
|
|
let result = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
page.evaluate_runtime_expression_with_await_async(&expression, true),
|
|
)
|
|
.await??;
|
|
let result: serde_json::Value = serde_json::from_str(
|
|
result["value"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("child CSP: {result}")),
|
|
)?;
|
|
assert_eq!(
|
|
result["status"],
|
|
if report_only { "loaded" } else { "TypeError" },
|
|
"{result}"
|
|
);
|
|
assert_eq!(result["events"].as_array().unwrap().len(), 1, "{result}");
|
|
assert_eq!(result["events"][0][0], "connect-src");
|
|
assert_eq!(
|
|
result["events"][0][1],
|
|
if report_only { "report" } else { "enforce" }
|
|
);
|
|
assert_eq!(
|
|
servers
|
|
.requests
|
|
.lock()
|
|
.iter()
|
|
.filter(|request| request.path == "/module-csp.text?return")
|
|
.count(),
|
|
usize::from(report_only)
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn script_and_wasm_module_csp_redirects_block_before_network_dispatch() -> Result<()> {
|
|
for case in ["redirect", "roundtrip", "roundtrip-report-only"] {
|
|
let servers = IntegrityServers::spawn().await?;
|
|
let browser = Browser::new(AppConfig::default())?;
|
|
let mut page = browser
|
|
.fetch(&format!("{}/module-csp.html?script-{case}", servers.origin))
|
|
.await?;
|
|
let query = if case == "redirect" {
|
|
"redirect"
|
|
} else {
|
|
"roundtrip"
|
|
};
|
|
let expression = format!(
|
|
r#"(async () => {{
|
|
const results = [];
|
|
for (const suffix of ['js', 'wasm']) {{
|
|
results.push(await import('/module-csp.' + suffix + '?{query}').then(() => 'loaded', e => e.name));
|
|
}}
|
|
if (moduleViolations.length < 2) await new Promise(resolve => {{
|
|
const observe = () => {{ if (moduleViolations.length >= 2) {{
|
|
document.removeEventListener('module-violation-observed', observe); resolve();
|
|
}} }};
|
|
document.addEventListener('module-violation-observed', observe);
|
|
}});
|
|
return JSON.stringify({{results, events:moduleViolations}});
|
|
}})()"#
|
|
);
|
|
let result = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
page.evaluate_runtime_expression_with_await_async(&expression, true),
|
|
)
|
|
.await??;
|
|
let result: serde_json::Value = serde_json::from_str(
|
|
result["value"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("script CSP: {result}")),
|
|
)?;
|
|
let report_only = case.ends_with("report-only");
|
|
let status = if report_only { "loaded" } else { "TypeError" };
|
|
assert_eq!(
|
|
result["results"],
|
|
serde_json::json!([status, status]),
|
|
"{case}: {result}"
|
|
);
|
|
assert_eq!(
|
|
result["events"].as_array().unwrap().len(),
|
|
2,
|
|
"{case}: {result}"
|
|
);
|
|
for event in result["events"].as_array().unwrap() {
|
|
assert_eq!(event[0], "script-src-elem");
|
|
assert_eq!(event[1], if report_only { "report" } else { "enforce" });
|
|
}
|
|
let requests = servers.requests.lock();
|
|
for suffix in ["js", "wasm"] {
|
|
let prefix = format!("/module-csp.{suffix}?");
|
|
assert_eq!(
|
|
requests
|
|
.iter()
|
|
.filter(|r| r.path.starts_with(&prefix))
|
|
.count(),
|
|
if report_only { 3 } else { 1 },
|
|
"{case}: {requests:?}"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn modulepreload_csp_redirects_block_before_dispatch_and_report_intermediate_hops()
|
|
-> Result<()> {
|
|
for case in ["redirect", "roundtrip", "roundtrip-report-only"] {
|
|
let servers = IntegrityServers::spawn().await?;
|
|
let browser = Browser::new(AppConfig::default())?;
|
|
let mut page = browser
|
|
.fetch(&format!(
|
|
"{}/module-csp.html?preload-{case}",
|
|
servers.origin
|
|
))
|
|
.await?;
|
|
let result = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
page.evaluate_runtime_expression_with_await_async(
|
|
r#"(async () => {
|
|
const preloads = await preloadsReady;
|
|
const imports = [];
|
|
for (const [as, type] of [['text','text'], ['json','json'], ['style','css']]) {
|
|
const link = document.querySelector('link[as="' + as + '"]');
|
|
imports.push(await import(link.href, {with:{type}}).then(() => 'loaded', e => e.name));
|
|
}
|
|
if (moduleViolations.length < 3) await new Promise(resolve => {
|
|
const observe = () => { if (moduleViolations.length >= 3) {
|
|
document.removeEventListener('module-violation-observed', observe); resolve();
|
|
} };
|
|
document.addEventListener('module-violation-observed', observe);
|
|
});
|
|
return JSON.stringify({preloads, imports, events:moduleViolations});
|
|
})()"#,
|
|
true,
|
|
),
|
|
)
|
|
.await??;
|
|
let result: serde_json::Value = serde_json::from_str(
|
|
result["value"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("preload CSP: {result}")),
|
|
)?;
|
|
let report_only = case.ends_with("report-only");
|
|
assert_eq!(
|
|
result["preloads"],
|
|
serde_json::json!({
|
|
"text": if report_only { "load" } else { "error" },
|
|
"json": if report_only { "load" } else { "error" },
|
|
"style": if report_only { "load" } else { "error" },
|
|
}),
|
|
"{case}: {result}"
|
|
);
|
|
let status = if report_only { "loaded" } else { "TypeError" };
|
|
assert_eq!(
|
|
result["imports"],
|
|
serde_json::json!([status, status, status]),
|
|
"{case}: {result}"
|
|
);
|
|
assert_eq!(
|
|
result["events"].as_array().unwrap().len(),
|
|
3,
|
|
"{case}: {result}"
|
|
);
|
|
let requests = servers.requests.lock();
|
|
for suffix in ["text", "json", "css"] {
|
|
let prefix = format!("/module-csp.{suffix}");
|
|
assert_eq!(
|
|
requests
|
|
.iter()
|
|
.filter(|r| r.path.starts_with(&prefix))
|
|
.count(),
|
|
if report_only { 3 } else { 1 },
|
|
"{case}: {requests:?}"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|