use super::*; use parking_lot::Mutex; use std::sync::Arc; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener, task::{JoinHandle, JoinSet}, }; const SCRIPT: &str = "globalThis.sriExecutions = (globalThis.sriExecutions || 0) + 1;"; const INTEGRITY: &str = "sha384-T7tuz8k7Hz0eBaWUPKiAEECRmaKHLJ1eRz7NF4VdK1fN++IaKD3hEk0SETOP+8aJ"; struct IntegrityServers { origin: String, cross_origin: String, requests: Arc>>, tasks: Vec>, } #[derive(Debug)] struct IntegrityRequest { method: String, path: String, origin: Option, cookie: Option, sec_fetch_dest: Option, sec_fetch_mode: Option, sec_fetch_site: Option, } impl Drop for IntegrityServers { fn drop(&mut self) { for task in &self.tasks { task.abort(); } } } impl IntegrityServers { async fn spawn() -> Result { let main = TcpListener::bind("127.0.0.1:0").await?; let cross = TcpListener::bind("127.0.0.1:0").await?; let origin = format!("http://{}", main.local_addr()?); let cross_origin = format!("http://{}", cross.local_addr()?); let requests = Arc::new(Mutex::new(Vec::new())); let tasks = [main, cross] .into_iter() .map(|listener| { let origin = origin.clone(); let cross_origin = cross_origin.clone(); 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 origin = origin.clone(); let cross_origin = cross_origin.clone(); let requests = Arc::clone(&requests); connections.spawn(async move { let mut request = Vec::new(); while !request.ends_with(b"\r\n\r\n") && request.len() < 16384 { let Ok(byte) = stream.read_u8().await else { return }; request.push(byte); } let request = String::from_utf8_lossy(&request); let path = request.split_whitespace().nth(1).unwrap_or("/"); let header = |name: &str| request.lines().filter_map(|line| line.split_once(':')) .find(|(key, _)| key.eq_ignore_ascii_case(name)) .map(|(_, value)| value.trim().to_owned()); let incoming = IntegrityRequest { method: request.split_whitespace().next().unwrap_or("GET").to_owned(), path: path.to_owned(), origin: header("Origin"), cookie: header("Cookie"), sec_fetch_dest: header("Sec-Fetch-Dest"), sec_fetch_mode: header("Sec-Fetch-Mode"), sec_fetch_site: header("Sec-Fetch-Site"), }; let (status, headers, body) = fixture_response(&incoming, &origin, &cross_origin); requests.lock().push(incoming); let response = format!( "HTTP/1.1 {status}\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_origin, requests, tasks, }) } } fn network_cases(origin: &str, cross: &str) -> serde_json::Value { serde_json::json!([ {"name": "same-origin", "src": format!("{origin}/script.js"), "integrity": INTEGRITY, "expected": "load"}, {"name": "cross-no-cors", "src": format!("{cross}/script.js"), "integrity": INTEGRITY, "expected": "error"}, {"name": "cross-anonymous", "src": format!("{cross}/cors.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "load"}, {"name": "cross-no-acao", "src": format!("{cross}/script.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "error"}, {"name": "cors-without-integrity", "src": format!("{cross}/script.js"), "crossOrigin": "anonymous", "expected": "error"}, {"name": "cors-empty-integrity", "src": format!("{cross}/script.js"), "integrity": "", "crossOrigin": "anonymous", "expected": "error"}, {"name": "cors-unsupported-integrity", "src": format!("{cross}/script.js"), "integrity": "sha1-ignored", "crossOrigin": "anonymous", "expected": "error"}, {"name": "redirect-cross", "src": format!("{origin}/redirect.js"), "integrity": INTEGRITY, "expected": "error"}, {"name": "redirect-home", "src": format!("{origin}/roundtrip.js"), "integrity": INTEGRITY, "expected": "error"}, {"name": "cors-redirect", "src": format!("{origin}/cors-redirect.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "load"}, {"name": "cors-unapproved-hop", "src": format!("{cross}/unapproved-redirect.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "error"}, {"name": "cors-null-origin", "src": format!("{cross}/cors-redirect-null.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "load"}, {"name": "cors-old-origin", "src": format!("{cross}/cors-redirect-origin.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "error"}, {"name": "absent-integrity", "src": format!("{cross}/script.js"), "expected": "load"}, {"name": "empty-integrity", "src": format!("{cross}/script.js"), "integrity": "", "expected": "load"}, {"name": "unsupported-integrity", "src": format!("{cross}/script.js"), "integrity": "sha1-ignored", "expected": "load"}, {"name": "mismatch", "src": format!("{origin}/script.js"), "integrity": "sha384-foobar", "expected": "error"} ]) } fn fixture_response( request: &IntegrityRequest, origin: &str, cross: &str, ) -> (&'static str, String, String) { let (path, query) = request.path.split_once('?').unwrap_or((&request.path, "")); let javascript = "Content-Type: text/javascript\r\n"; match path { "/echo-origin.js" | "/echo-origin-redirect.js" | "/echo-origin-home.js" | "/echo-origin-roundtrip.js" | "/module-graph.js" => { // Echo the actual request Origin: a missing header must not silently // receive ACAO: null and hide a broken redirect handoff. let mut headers = format!( "{javascript}Cache-Control: no-store\r\nVary: Origin\r\nX-Sri-Private: yes\r\n" ); if let Some(origin) = &request.origin { headers.push_str(&format!("Access-Control-Allow-Origin: {origin}\r\nAccess-Control-Allow-Credentials: true\r\n")); } if request.method == "OPTIONS" { headers.push_str("Access-Control-Allow-Methods: GET, PUT\r\nAccess-Control-Allow-Headers: x-sri-test\r\n"); return ("204 No Content", headers, String::new()); } let target = match path { "/echo-origin-redirect.js" => Some(format!("{cross}/echo-origin.js?{query}")), "/echo-origin-home.js" => Some(format!("{origin}/echo-origin.js?{query}")), "/echo-origin-roundtrip.js" => Some(format!("{cross}/echo-origin-home.js?{query}")), _ => None, }; if let Some(target) = target { headers.push_str(&format!("Location: {target}\r\n")); ("302 Found", headers, String::new()) } else if path == "/module-graph.js" { ( "200 OK", headers, format!( "import '{cross}/{}';", if query == "graph-blocked" { "script.js?blocked-static" } else { "echo-origin.js?static-dependency" } ), ) } else { ("200 OK", headers, SCRIPT.to_owned()) } } "/style.css" | "/style-roundtrip.css" | "/style-home.css" | "/app.webmanifest" | "/manifest-roundtrip.webmanifest" | "/manifest-home.webmanifest" => { let mut headers = "Cache-Control: no-store\r\nVary: Origin\r\n".to_owned(); if let Some(incoming_origin) = &request.origin && !(query == "bad-hop" && path.contains("-home.")) { let allowed_origin = if query == "bad-final" && !path.contains('-') { origin } else { incoming_origin }; headers.push_str(&format!( "Access-Control-Allow-Origin: {allowed_origin}\r\n" )); } let target = match path { "/style-roundtrip.css" => Some(format!("{cross}/style-home.css?{query}")), "/style-home.css" => Some(format!("{origin}/style.css?{query}")), "/manifest-roundtrip.webmanifest" => { Some(format!("{cross}/manifest-home.webmanifest?{query}")) } "/manifest-home.webmanifest" => Some(format!("{origin}/app.webmanifest?{query}")), _ => None, }; if let Some(target) = target { headers.push_str(&format!("Location: {target}\r\n")); ("302 Found", headers, String::new()) } else if path.ends_with(".css") { headers.push_str("Content-Type: text/css\r\n"); ("200 OK", headers, "body { color: green; }".to_owned()) } else { headers.push_str("Content-Type: application/manifest+json\r\n"); ( "200 OK", headers, r#"{"name":"Redirected manifest"}"#.to_owned(), ) } } "/manifest-page.html" => ( "200 OK", "Content-Type: text/html\r\n".to_owned(), format!( "", if query == "same-origin" { "app.webmanifest" } else { "manifest-roundtrip.webmanifest" } ), ), "/script.js" => ("200 OK", javascript.to_owned(), SCRIPT.to_owned()), "/cacheable.js" => ( "200 OK", format!("{javascript}Cache-Control: max-age=60\r\n"), SCRIPT.to_owned(), ), "/cors.js" | "/cors-null.js" | "/cors-origin.js" => ( "200 OK", format!( "{javascript}Access-Control-Allow-Origin: {}\r\n", match path { "/cors-null.js" => "null", "/cors-origin.js" => origin, _ => "*", } ), SCRIPT.to_owned(), ), "/redirect.js" | "/roundtrip.js" | "/redirect-home.js" | "/cors-redirect.js" | "/unapproved-redirect.js" | "/cors-redirect-null.js" | "/cors-redirect-origin.js" => { let target = match path { "/redirect.js" => format!("{cross}/script.js"), "/roundtrip.js" => format!("{cross}/redirect-home.js"), "/cors-redirect.js" => format!("{cross}/cors.js"), "/unapproved-redirect.js" => format!("{origin}/cors.js"), "/cors-redirect-null.js" => format!("{origin}/cors-null.js"), "/cors-redirect-origin.js" => format!("{origin}/cors-origin.js"), _ => format!("{origin}/script.js"), }; let acao = if path.starts_with("/cors-redirect") { "Access-Control-Allow-Origin: *\r\n" } else { "" }; ( "302 Found", format!("Location: {target}\r\n{acao}"), String::new(), ) } "/worker.js" => { let body = format!( r#" self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); self.addEventListener('activate', event => event.waitUntil(clients.claim())); self.addEventListener('fetch', event => {{ const path = new URL(event.request.url).pathname; if (path === '/sw-opaque.js') event.respondWith(fetch('{cross}/script.js', {{mode: 'no-cors'}})); else if (path === '/sw-basic.js') event.respondWith(fetch('{origin}/script.js')); else if (path === '/sw-cors.js') event.respondWith(fetch('{cross}/cors.js')); else if (path === '/sw-default.js') event.respondWith(new Response({script}, {{headers: {{'Content-Type': 'text/javascript'}}}})); else if (path === '/sw-redirect.js') event.respondWith(Response.redirect('{cross}/script.js')); else if (path === '/sw-roundtrip.js') event.respondWith(Response.redirect('{cross}/redirect-home.js')); else if (path === '/sw-cors-redirect.js') event.respondWith(Response.redirect('{origin}/cors.js')); else if (path === '/sw-cors-redirect-null.js') event.respondWith(Response.redirect('{origin}/cors-null.js')); else if (path === '/sw-cors-redirect-origin.js') event.respondWith(Response.redirect('{origin}/cors-origin.js')); else if (path === '/sw-cors-redirect-no-acao.js') event.respondWith(Response.redirect('{origin}/script.js')); else if (path === '/sw-cors-redirect-unapproved-hop.js') event.respondWith(Response.redirect('{cross}/unapproved-redirect.js')); else if (path === '/sw-request-state.js') event.respondWith(Response.redirect('{origin}/echo-origin.js' + new URL(event.request.url).search)); else if (path === '/sw-request-state-redirect.js') event.respondWith(Response.redirect('{origin}/echo-origin-redirect.js' + new URL(event.request.url).search)); else if (path === '/sw-cacheable.js') event.respondWith(Response.redirect('{origin}/cacheable.js')); else if (path.startsWith('/sw-return-')) event.respondWith(Response.redirect('{origin}' + path.replace('/sw-return-', '/sw-final-'))); else if (path === '/sw-final-basic.js') event.respondWith(fetch('{origin}/echo-origin.js?worker-basic')); else if (path === '/sw-final-basic-buffered.js') event.respondWith(fetch('{origin}/echo-origin.js?worker-basic-buffered').then(async response => {{ await response.clone().text(); return response; }})); else if (path === '/sw-final-cors.js') event.respondWith(fetch('{cross}/echo-origin.js?worker-cors')); else if (path === '/sw-final-default.js') event.respondWith(new Response({script}, {{headers: {{'Content-Type': 'text/javascript', 'X-Sri-Private': 'yes'}}}})); }}); "#, script = serde_json::to_string(SCRIPT).unwrap() ); ("200 OK", javascript.to_owned(), body) } "/page.html" | "/parser.html" | "/cookie-page.html" | "/csp-page.html" => { let mut html = "".to_owned(); if path == "/csp-page.html" { html.insert_str(15, ""); } if path == "/parser.html" { for case in network_cases(origin, cross).as_array().unwrap() { let name = case["name"].as_str().unwrap(); html.push_str(&format!(""); } } let mut headers = "Content-Type: text/html\r\n".to_owned(); if path == "/cookie-page.html" { headers.push_str("Set-Cookie: sriSession=present; Path=/; SameSite=Lax\r\n"); } ("200 OK", headers, html) } _ => ( "404 Not Found", "Content-Type: text/plain\r\n".to_owned(), "not found".to_owned(), ), } } fn assert_integrity_results(result: serde_json::Value, cases: &serde_json::Value) { let result: serde_json::Value = serde_json::from_str(result["value"].as_str().expect("integrity result JSON")).unwrap(); let mut executions = 0; for case in cases.as_array().unwrap() { let name = case["name"].as_str().unwrap(); assert_eq!(result["events"][name], case["expected"], "{name}: {result}"); executions += usize::from(case["expected"] == "load"); } assert_eq!( result["executions"], executions, "rejected scripts must not execute" ); } fn dynamic_probe(cases: &serde_json::Value, service_worker: bool) -> String { format!( r#"(async () => {{ if ({service_worker}) {{ const controlled = navigator.serviceWorker.controller ? Promise.resolve() : new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, {{once: true}})); await navigator.serviceWorker.register('/worker.js', {{scope: '/'}}); await navigator.serviceWorker.ready; await controlled; }} for (const test of {cases}) {{ sriEvents[test.name] = await new Promise(resolve => {{ const script = document.createElement('script'); script.src = test.src; if ('type' in test) script.type = test.type; if ('integrity' in test) script.integrity = test.integrity; if ('crossOrigin' in test) script.crossOrigin = test.crossOrigin; script.onload = () => resolve('load'); script.onerror = () => resolve('error'); document.body.append(script); }}); }} return JSON.stringify({{events: sriEvents, executions: sriExecutions}}); }})()"# ) } #[tokio::test(flavor = "multi_thread")] async fn parser_script_integrity_requires_readable_response() -> Result<()> { let servers = IntegrityServers::spawn().await?; let browser = Browser::new(AppConfig::default())?; let mut page = browser .fetch(&format!("{}/parser.html", servers.origin)) .await?; let result = page .evaluate_runtime_expression_async( "JSON.stringify({events: sriEvents, executions: sriExecutions})", ) .await?; assert_integrity_results( result, &network_cases(&servers.origin, &servers.cross_origin), ); Ok(()) } #[tokio::test(flavor = "multi_thread")] async fn dynamic_script_integrity_requires_readable_response() -> Result<()> { let servers = IntegrityServers::spawn().await?; let browser = Browser::new(AppConfig::default())?; let mut page = browser .fetch(&format!("{}/page.html", servers.origin)) .await?; let cases = network_cases(&servers.origin, &servers.cross_origin); let result = page .evaluate_runtime_expression_with_await_async(&dynamic_probe(&cases, false), true) .await?; assert_integrity_results(result, &cases); Ok(()) } #[tokio::test(flavor = "multi_thread")] async fn module_script_integrity_requires_readable_response() -> Result<()> { let servers = IntegrityServers::spawn().await?; let browser = Browser::new(AppConfig::default())?; let mut page = browser .fetch(&format!("{}/page.html", servers.origin)) .await?; let cases = serde_json::json!([ {"name": "same-origin", "src": "/script.js", "type": "module", "integrity": INTEGRITY, "expected": "load"}, {"name": "cors", "src": format!("{}/cors.js", servers.cross_origin), "type": "module", "integrity": INTEGRITY, "expected": "load"}, {"name": "no-acao", "src": format!("{}/script.js", servers.cross_origin), "type": "module", "integrity": INTEGRITY, "expected": "error"}, {"name": "without-integrity", "src": format!("{}/script.js?absent", servers.cross_origin), "type": "module", "expected": "error"}, {"name": "empty-integrity", "src": format!("{}/script.js?empty", servers.cross_origin), "type": "module", "integrity": "", "expected": "error"}, {"name": "unsupported-integrity", "src": format!("{}/script.js?unsupported", servers.cross_origin), "type": "module", "integrity": "sha1-ignored", "expected": "error"} ]); let result = page .evaluate_runtime_expression_with_await_async(&dynamic_probe(&cases, false), true) .await?; assert_integrity_results(result, &cases); Ok(()) } #[tokio::test(flavor = "multi_thread")] async fn direct_scripts_send_origin_and_fetch_metadata() -> Result<()> { let servers = IntegrityServers::spawn().await?; let browser = Browser::new(AppConfig::default())?; let mut page = browser .fetch(&format!("{}/cookie-page.html", servers.origin)) .await?; let origin = &servers.origin; let cross = &servers.cross_origin; let cases = serde_json::json!([ {"name": "control", "src": "/echo-origin.js?control", "type": "module", "integrity": INTEGRITY, "expected": "load"}, {"name": "classic", "src": format!("{cross}/echo-origin.js?classic"), "crossOrigin": "anonymous", "integrity": INTEGRITY, "expected": "load"}, {"name": "module", "src": format!("{cross}/echo-origin.js?module"), "type": "module", "integrity": INTEGRITY, "expected": "load"}, {"name": "include", "src": format!("{cross}/echo-origin.js?include"), "type": "module", "crossOrigin": "use-credentials", "integrity": INTEGRITY, "expected": "load"}, {"name": "home", "src": format!("{cross}/echo-origin-home.js?home"), "type": "module", "integrity": INTEGRITY, "expected": "load"}, {"name": "roundtrip", "src": "/echo-origin-roundtrip.js?roundtrip", "type": "module", "integrity": INTEGRITY, "expected": "load"}, {"name": "no-cors", "src": format!("{cross}/echo-origin.js?no-cors"), "expected": "load"}, {"name": "graph", "src": "/module-graph.js?graph", "type": "module", "expected": "load"}, {"name": "graph-blocked", "src": "/module-graph.js?graph-blocked", "type": "module", "expected": "error"} ]); let result = page .evaluate_runtime_expression_with_await_async(&dynamic_probe(&cases, false), true) .await?; assert_integrity_results(result, &cases); let imported = page .evaluate_runtime_expression_with_await_async( &format!("import('{cross}/echo-origin.js?dynamic-import').then(() => 'loaded')"), true, ) .await?; assert_eq!(imported["value"], "loaded"); let rejected = page .evaluate_runtime_expression_with_await_async( &format!( "import('{cross}/script.js?blocked-dynamic').then(() => 'loaded', () => 'rejected')" ), true, ) .await?; assert_eq!(rejected["value"], "rejected"); let executions = page .evaluate_runtime_expression_async("sriExecutions") .await?; assert_eq!( executions["value"], 9, "CORS-rejected dependencies must not execute" ); let requests = servers.requests.lock(); for name in [ "control", "classic", "module", "include", "home", "roundtrip", "no-cors", "graph", "graph-blocked", "static-dependency", "dynamic-import", "blocked-static", "blocked-dynamic", ] { let actual: Vec<_> = requests .iter() .filter(|request| request.path.ends_with(&format!("?{name}"))) .collect(); let expected_origins = match name { "home" => vec![Some(origin.as_str()), Some("null")], "roundtrip" => vec![None, Some(origin.as_str()), Some("null")], "control" | "graph" | "graph-blocked" | "no-cors" => vec![None], _ => vec![Some(origin.as_str())], }; assert_eq!(actual.len(), expected_origins.len(), "{name}: {actual:?}"); for (index, (request, expected_origin)) in actual.iter().zip(expected_origins).enumerate() { assert_eq!( request.origin.as_deref(), expected_origin, "{name}: {request:?}" ); assert_eq!( request.sec_fetch_dest.as_deref(), Some("script"), "{name}: {request:?}" ); assert_eq!( request.sec_fetch_mode.as_deref(), Some(if name == "no-cors" { "no-cors" } else { "cors" }), "{name}: {request:?}" ); let same_origin = matches!(name, "control" | "graph" | "graph-blocked") || (name == "roundtrip" && index == 0); assert_eq!( request.sec_fetch_site.as_deref(), Some(if same_origin { "same-origin" } else { "same-site" }), "{name}: {request:?}" ); assert_eq!( request.cookie.as_deref(), if same_origin || matches!(name, "include" | "no-cors") { Some("sriSession=present") } else { None }, "{name}: {request:?}" ); } } Ok(()) } #[tokio::test(flavor = "multi_thread")] async fn srcdoc_module_requests_use_window_origin_instead_of_base_url() -> Result<()> { let servers = IntegrityServers::spawn().await?; let browser = Browser::new(AppConfig::default())?; let mut page = browser .fetch(&format!("{}/cookie-page.html", servers.origin)) .await?; let result = page.evaluate_runtime_expression_with_await_async(&format!(r#"(async () => {{ const results = []; for (const kind of ['module', 'classic', 'preload', 'dynamic']) {{ for (const originKind of ['same-origin', 'inherited', 'sandboxed']) {{ const name = originKind + '-' + kind; const frame = document.createElement('iframe'); if (originKind === 'sandboxed') frame.setAttribute('sandbox', 'allow-scripts'); const terminal = new Promise(resolve => {{ const handler = event => {{ if (event.source !== frame.contentWindow || !String(event.data).startsWith(name + ':')) return; removeEventListener('message', handler); resolve(event.data); }}; addEventListener('message', handler); }}); const terminalAttributes = `onload="parent.postMessage('${{name}}:load', '*')" onerror="parent.postMessage('${{name}}:error', '*')"`; const source = './echo-origin.js?' + name; const resource = kind === 'preload' ? `` : kind === 'dynamic' ? `