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, cookie: Option, } struct SecurityServers { origin: String, cross: String, requests: Arc>>, tasks: Vec>, } impl Drop for SecurityServers { fn drop(&mut self) { for task in &self.tasks { task.abort(); } } } impl SecurityServers { async fn spawn() -> Result { 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(), "fetch security".to_owned(), ); } if url.path() == "/blob-creator" { return ( 200, "Content-Type: text/html\r\n".to_owned(), r#""# .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#" "#.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#""#, ) } 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 { 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::>(); 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(()) }