diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/live_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/denied_requests_tests.rs similarity index 54% rename from crates/tinycomputer-browser/src/linked/linked_tests/live_tests.rs rename to crates/tinycomputer-browser/src/linked/linked_tests/live_tests/denied_requests_tests.rs index 556a1432..3882f41b 100644 --- a/crates/tinycomputer-browser/src/linked/linked_tests/live_tests.rs +++ b/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/denied_requests_tests.rs @@ -1,47 +1,39 @@ -//! Live Chromium egress tests that use local HTTP and WebSocket fixtures. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::EnforcedAgentBrowser; -use crate::engine::Launcher; -use crate::{BrowserDialTarget, DialGrant, EgressAuthorizer}; -use serde_json::json; -use std::{ - io, - net::SocketAddr, - sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, - }, - time::Duration, -}; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, -}; +use super::*; #[tokio::test] #[cfg(live_browser_tests)] -async fn live_chromium_loopback_navigation_uses_the_authenticated_session_proxy() { +async fn live_chromium_subresource_never_reaches_a_denied_destination() { + let denied = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + denied.set_nonblocking(true).unwrap(); + let denied_address = denied.local_addr().unwrap(); + let denied_targets = Arc::new(Mutex::new(Vec::new())); let fixture = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = fixture.local_addr().unwrap(); - let server = tokio::spawn(async move { + let body = format!( + "", + denied_address.port() + ); + let fixture_server = tokio::spawn(async move { let (mut socket, _) = fixture.accept().await.unwrap(); let mut request = [0; 4096]; let _ = socket.read(&mut request).await.unwrap(); socket .write_all( - b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 22\r\nConnection: close\r\n\r\n

proxy fixture

", + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), body + ) + .as_bytes(), ) .await .unwrap(); }); - let authorizations = Arc::new(AtomicUsize::new(0)); let mut browser = EnforcedAgentBrowser::new(Arc::new(RejectAllButFixture { address, - authorizations: authorizations.clone(), - denied_targets: Arc::new(Mutex::new(Vec::new())), + authorizations: Arc::new(AtomicUsize::new(0)), + denied_targets: denied_targets.clone(), })) - .open("live-egress"); + .open("live-egress-subresource"); let launch = browser .execute(json!({"id":"launch","action":"launch","headless":true,"args":[]})) .await; @@ -53,97 +45,71 @@ async fn live_chromium_loopback_navigation_uses_the_authenticated_session_proxy( "url":format!("http://127.0.0.1:{}/", address.port()) })) .await; + wait_for_denied_target(&denied_targets, denied_address.port()).await; browser .execute(json!({"id":"close","action":"close"})) .await; - assert_eq!( - navigation["success"], - true, - "{navigation}; proxy authorizations: {}", - authorizations.load(Ordering::SeqCst) - ); - server.await.unwrap(); -} - -#[tokio::test] -#[cfg(live_browser_tests)] -async fn live_chromium_dials_only_the_exact_host_authorized_socket() { - let allowed = TcpListener::bind("127.0.0.2:0").await.unwrap(); - let allowed_address = allowed.local_addr().unwrap(); - let denied = std::net::TcpListener::bind(("127.0.0.1", allowed_address.port())).unwrap(); - denied.set_nonblocking(true).unwrap(); - - let allowed_server = tokio::spawn(async move { - let (mut socket, _) = allowed.accept().await.unwrap(); - let mut request = [0; 4096]; - let _ = socket.read(&mut request).await.unwrap(); - socket - .write_all( - b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 30\r\nConnection: close\r\n\r\n

pinned socket fixture

", - ) - .await - .unwrap(); - }); - let mut browser = EnforcedAgentBrowser::new(Arc::new(AuthorizeExactSocket(allowed_address))) - .open("live-egress-socket-pinning"); - let launch = browser - .execute(json!({"id":"launch","action":"launch","headless":true,"args":[]})) - .await; - assert_eq!(launch["success"], true, "{launch}"); - let navigation = browser - .execute(json!({ - "id":"navigate", - "action":"navigate", - "url":format!("http://127.0.0.1:{}/", allowed_address.port()) - })) - .await; - browser - .execute(json!({"id":"close","action":"close"})) - .await; - assert_eq!(navigation["success"], true, "{navigation}"); - allowed_server.await.unwrap(); + fixture_server.await.unwrap(); assert_eq!( denied.accept().unwrap_err().kind(), io::ErrorKind::WouldBlock, - "Chromium must reach only the exact socket returned by the host grant" + "the denied subresource destination must never receive a connection" ); } #[tokio::test] #[cfg(live_browser_tests)] -async fn live_chromium_subresource_never_reaches_a_denied_destination() { +async fn live_chromium_worker_fetch_never_reaches_a_denied_destination() { let denied = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); denied.set_nonblocking(true).unwrap(); let denied_address = denied.local_addr().unwrap(); let denied_targets = Arc::new(Mutex::new(Vec::new())); let fixture = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = fixture.local_addr().unwrap(); - let body = format!( - "", + let html = "

worker fixture

".to_string(); + let worker = format!( + "fetch('http://127.0.0.1:{}/private').catch(() => {{}});", denied_address.port() ); let fixture_server = tokio::spawn(async move { - let (mut socket, _) = fixture.accept().await.unwrap(); - let mut request = [0; 4096]; - let _ = socket.read(&mut request).await.unwrap(); - socket - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), body + loop { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(10), fixture.accept()) + .await + .expect("Chromium requests the page and worker script") + .unwrap(); + let mut request = [0; 4096]; + let size = socket.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..size]); + let (status, content_type, body, worker_script) = if request.contains("GET /worker.js") + { + ("200 OK", "application/javascript", worker.as_str(), true) + } else if request.contains("GET / ") { + ("200 OK", "text/html", html.as_str(), false) + } else { + ("404 Not Found", "text/plain", "not found", false) + }; + socket + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), ) - .as_bytes(), - ) - .await - .unwrap(); + .await + .unwrap(); + if worker_script { + break; + } + } }); let mut browser = EnforcedAgentBrowser::new(Arc::new(RejectAllButFixture { address, authorizations: Arc::new(AtomicUsize::new(0)), denied_targets: denied_targets.clone(), })) - .open("live-egress-subresource"); + .open("live-egress-worker"); let launch = browser .execute(json!({"id":"launch","action":"launch","headless":true,"args":[]})) .await; @@ -164,7 +130,7 @@ async fn live_chromium_subresource_never_reaches_a_denied_destination() { assert_eq!( denied.accept().unwrap_err().kind(), io::ErrorKind::WouldBlock, - "the denied subresource destination must never receive a connection" + "the denied worker fetch destination must never receive a connection" ); } @@ -279,122 +245,3 @@ async fn live_chromium_websocket_never_reaches_a_denied_destination() { "the denied WebSocket destination must never receive a connection" ); } - -#[cfg(live_browser_tests)] -#[derive(Debug)] -struct RejectAllButFixture { - address: SocketAddr, - authorizations: Arc, - denied_targets: Arc>>, -} - -#[cfg(live_browser_tests)] -#[derive(Debug)] -struct AuthorizeExactSocket(SocketAddr); - -#[cfg(live_browser_tests)] -impl EgressAuthorizer for AuthorizeExactSocket { - fn authorize( - &self, - _: BrowserDialTarget, - ) -> std::pin::Pin> + Send>> { - let address = self.0; - Box::pin(async move { - Ok(DialGrant { - lease: "live-pinned-socket-lease".into(), - addresses: vec![address], - }) - }) - } - - fn consume( - &self, - _: BrowserDialTarget, - grant: DialGrant, - address: SocketAddr, - ) -> std::pin::Pin> + Send>> { - Box::pin(async move { - if grant.addresses.contains(&address) { - Ok(()) - } else { - Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) - } - }) - } - - fn validate( - &self, - _: String, - ) -> std::pin::Pin> + Send>> { - Box::pin(async { Ok(true) }) - } -} - -#[cfg(live_browser_tests)] -async fn wait_for_denied_target(denied_targets: &Arc>>, port: u16) { - tokio::time::timeout(Duration::from_secs(3), async { - loop { - let was_denied = denied_targets - .lock() - .unwrap() - .iter() - .any(|(_, denied_port)| *denied_port == port); - if was_denied { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("Chromium should attempt the denied destination"); -} - -#[cfg(live_browser_tests)] -impl EgressAuthorizer for RejectAllButFixture { - fn authorize( - &self, - target: BrowserDialTarget, - ) -> std::pin::Pin> + Send>> { - let address = self.address; - self.authorizations.fetch_add(1, Ordering::SeqCst); - let allowed = target.hostname == "127.0.0.1" && target.port == address.port(); - if !allowed { - self.denied_targets - .lock() - .unwrap() - .push((target.hostname, target.port)); - } - Box::pin(async move { - if allowed { - Ok(DialGrant { - lease: "live-fixture-lease".into(), - addresses: vec![address], - }) - } else { - Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) - } - }) - } - - fn consume( - &self, - _: BrowserDialTarget, - grant: DialGrant, - address: SocketAddr, - ) -> std::pin::Pin> + Send>> { - Box::pin(async move { - if grant.addresses.contains(&address) { - Ok(()) - } else { - Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) - } - }) - } - - fn validate( - &self, - _: String, - ) -> std::pin::Pin> + Send>> { - Box::pin(async { Ok(true) }) - } -} diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/dns_rebinding_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/dns_rebinding_tests.rs new file mode 100644 index 00000000..557d7a0e --- /dev/null +++ b/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/dns_rebinding_tests.rs @@ -0,0 +1,91 @@ +use super::*; + +#[tokio::test] +#[cfg(live_browser_tests)] +async fn live_chromium_dns_rebinding_never_reaches_the_rebound_private_socket() { + let allowed_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = allowed_listener.local_addr().unwrap().port(); + let private = std::net::TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, port)).unwrap(); + private.set_nonblocking(true).unwrap(); + let private_address = private.local_addr().unwrap(); + let allowed_address = allowed_listener.local_addr().unwrap(); + let extra_allowed_connections = Arc::new(AtomicUsize::new(0)); + let observed_addresses = Arc::new(Mutex::new(Vec::new())); + let extra_connections = extra_allowed_connections.clone(); + let body = format!("

fixture

"); + let allowed_server = tokio::spawn(async move { + let (mut socket, _) = + tokio::time::timeout(Duration::from_secs(10), allowed_listener.accept()) + .await + .expect("authorized navigation reaches the allowed fixture") + .unwrap(); + let mut request = [0; 4096]; + let _ = socket.read(&mut request).await.unwrap(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + drop(socket); + + while let Ok((socket, _)) = allowed_listener.accept().await { + extra_connections.fetch_add(1, Ordering::SeqCst); + drop(socket); + } + }); + let denied_targets = Arc::new(Mutex::new(Vec::new())); + let mut browser = EnforcedAgentBrowser::new(Arc::new(RebindingAuthorizer { + allowed_address, + private_address, + authorizations: AtomicUsize::new(0), + denied_targets: denied_targets.clone(), + observed_addresses: observed_addresses.clone(), + })) + .open("live-egress-dns-rebinding"); + let launch = browser + .execute(json!({"id":"launch","action":"launch","headless":true,"args":[]})) + .await; + assert_eq!(launch["success"], true, "{launch}"); + let navigation = browser + .execute(json!({ + "id":"navigate", + "action":"navigate", + "url":format!("http://fixture.test:{port}/") + })) + .await; + wait_for_denied_target(&denied_targets, port).await; + browser + .execute(json!({"id":"close","action":"close"})) + .await; + assert_eq!(navigation["success"], true, "{navigation}"); + let observed_addresses = observed_addresses.lock().unwrap().clone(); + assert_eq!( + observed_addresses.first(), + Some(&allowed_address), + "the controlled resolver first returns the permitted address" + ); + assert!( + observed_addresses.len() >= 2 + && observed_addresses[1..] + .iter() + .all(|address| *address == private_address), + "the controlled resolver must rebind the name to the private address: {observed_addresses:?}" + ); + assert!( + tokio::time::timeout(Duration::from_millis(500), async { + while extra_allowed_connections.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .is_err(), + "the denied rebound request reached the previously allowed socket" + ); + assert_eq!( + private.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock, + "the rebound private destination must never receive a connection" + ); + allowed_server.abort(); + let _ = allowed_server.await; +} diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/mod.rs b/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/mod.rs new file mode 100644 index 00000000..92045cc2 --- /dev/null +++ b/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/mod.rs @@ -0,0 +1,270 @@ +//! Live Chromium egress tests that use local HTTP and WebSocket fixtures. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::EnforcedAgentBrowser; +use crate::engine::Launcher; +use crate::{BrowserDialTarget, DialGrant, EgressAuthorizer}; +use serde_json::json; +use std::{ + collections::HashMap, + io, + net::SocketAddr, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +mod denied_requests_tests; +mod dns_rebinding_tests; +mod navigation_tests; + +#[cfg(live_browser_tests)] +#[derive(Debug)] +struct RejectAllButFixture { + address: SocketAddr, + authorizations: Arc, + denied_targets: Arc>>, +} + +#[cfg(live_browser_tests)] +#[derive(Debug)] +struct RebindingAuthorizer { + allowed_address: SocketAddr, + private_address: SocketAddr, + authorizations: AtomicUsize, + denied_targets: Arc>>, + observed_addresses: Arc>>, +} + +#[cfg(live_browser_tests)] +#[derive(Debug)] +struct AllowFixtureHosts { + addresses: HashMap, + authorizations: Arc, +} + +#[cfg(live_browser_tests)] +impl EgressAuthorizer for AllowFixtureHosts { + fn authorize( + &self, + target: BrowserDialTarget, + ) -> std::pin::Pin> + Send>> { + self.authorizations.fetch_add(1, Ordering::SeqCst); + let address = self + .addresses + .get(&target.hostname) + .copied() + .filter(|address| address.port() == target.port); + Box::pin(async move { + match address { + Some(address) => Ok(DialGrant { + lease: "live-cross-origin-fixture-lease".into(), + addresses: vec![address], + }), + None => Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")), + } + }) + } + + fn consume( + &self, + _: BrowserDialTarget, + grant: DialGrant, + address: SocketAddr, + ) -> std::pin::Pin> + Send>> { + Box::pin(async move { + if grant.addresses.contains(&address) { + Ok(()) + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) + } + }) + } + + fn validate( + &self, + _: String, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Ok(true) }) + } +} + +#[cfg(live_browser_tests)] +impl EgressAuthorizer for RebindingAuthorizer { + fn authorize( + &self, + target: BrowserDialTarget, + ) -> std::pin::Pin> + Send>> { + let attempt = self.authorizations.fetch_add(1, Ordering::SeqCst); + let resolved_address = if attempt == 0 { + self.allowed_address + } else { + self.private_address + }; + self.observed_addresses + .lock() + .unwrap() + .push(resolved_address); + let allowed = attempt == 0 + && target.hostname == "fixture.test" + && target.port == self.allowed_address.port(); + if !allowed { + self.denied_targets + .lock() + .unwrap() + .push((target.hostname, target.port)); + } + Box::pin(async move { + if allowed { + Ok(DialGrant { + lease: "live-dns-rebinding-lease".into(), + addresses: vec![resolved_address], + }) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "rebound private address denied", + )) + } + }) + } + + fn consume( + &self, + _: BrowserDialTarget, + grant: DialGrant, + address: SocketAddr, + ) -> std::pin::Pin> + Send>> { + Box::pin(async move { + if grant.addresses.contains(&address) { + Ok(()) + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) + } + }) + } + + fn validate( + &self, + _: String, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Ok(true) }) + } +} + +#[cfg(live_browser_tests)] +#[derive(Debug)] +struct AuthorizeExactSocket(SocketAddr); + +#[cfg(live_browser_tests)] +impl EgressAuthorizer for AuthorizeExactSocket { + fn authorize( + &self, + _: BrowserDialTarget, + ) -> std::pin::Pin> + Send>> { + let address = self.0; + Box::pin(async move { + Ok(DialGrant { + lease: "live-pinned-socket-lease".into(), + addresses: vec![address], + }) + }) + } + + fn consume( + &self, + _: BrowserDialTarget, + grant: DialGrant, + address: SocketAddr, + ) -> std::pin::Pin> + Send>> { + Box::pin(async move { + if grant.addresses.contains(&address) { + Ok(()) + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) + } + }) + } + + fn validate( + &self, + _: String, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Ok(true) }) + } +} + +#[cfg(live_browser_tests)] +async fn wait_for_denied_target(denied_targets: &Arc>>, port: u16) { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let was_denied = denied_targets + .lock() + .unwrap() + .iter() + .any(|(_, denied_port)| *denied_port == port); + if was_denied { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("Chromium should attempt the denied destination"); +} + +#[cfg(live_browser_tests)] +impl EgressAuthorizer for RejectAllButFixture { + fn authorize( + &self, + target: BrowserDialTarget, + ) -> std::pin::Pin> + Send>> { + let address = self.address; + self.authorizations.fetch_add(1, Ordering::SeqCst); + let allowed = target.hostname == "127.0.0.1" && target.port == address.port(); + if !allowed { + self.denied_targets + .lock() + .unwrap() + .push((target.hostname, target.port)); + } + Box::pin(async move { + if allowed { + Ok(DialGrant { + lease: "live-fixture-lease".into(), + addresses: vec![address], + }) + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) + } + }) + } + + fn consume( + &self, + _: BrowserDialTarget, + grant: DialGrant, + address: SocketAddr, + ) -> std::pin::Pin> + Send>> { + Box::pin(async move { + if grant.addresses.contains(&address) { + Ok(()) + } else { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) + } + }) + } + + fn validate( + &self, + _: String, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { Ok(true) }) + } +} diff --git a/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/navigation_tests.rs b/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/navigation_tests.rs new file mode 100644 index 00000000..d3b1e199 --- /dev/null +++ b/crates/tinycomputer-browser/src/linked/linked_tests/live_tests/navigation_tests.rs @@ -0,0 +1,158 @@ +use super::*; + +#[tokio::test] +#[cfg(live_browser_tests)] +async fn live_chromium_loopback_navigation_uses_the_authenticated_session_proxy() { + let fixture = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = fixture.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = fixture.accept().await.unwrap(); + let mut request = [0; 4096]; + let _ = socket.read(&mut request).await.unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 22\r\nConnection: close\r\n\r\n

proxy fixture

", + ) + .await + .unwrap(); + }); + let authorizations = Arc::new(AtomicUsize::new(0)); + let mut browser = EnforcedAgentBrowser::new(Arc::new(RejectAllButFixture { + address, + authorizations: authorizations.clone(), + denied_targets: Arc::new(Mutex::new(Vec::new())), + })) + .open("live-egress"); + let launch = browser + .execute(json!({"id":"launch","action":"launch","headless":true,"args":[]})) + .await; + assert_eq!(launch["success"], true, "{launch}"); + let navigation = browser + .execute(json!({ + "id":"navigate", + "action":"navigate", + "url":format!("http://127.0.0.1:{}/", address.port()) + })) + .await; + browser + .execute(json!({"id":"close","action":"close"})) + .await; + assert_eq!( + navigation["success"], + true, + "{navigation}; proxy authorizations: {}", + authorizations.load(Ordering::SeqCst) + ); + server.await.unwrap(); +} + +#[tokio::test] +#[cfg(live_browser_tests)] +async fn live_chromium_dials_only_the_exact_host_authorized_socket() { + let allowed = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let allowed_address = allowed.local_addr().unwrap(); + let denied = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + denied.set_nonblocking(true).unwrap(); + let denied_address = denied.local_addr().unwrap(); + assert_ne!(allowed_address.port(), denied_address.port()); + + let allowed_server = tokio::spawn(async move { + let (mut socket, _) = allowed.accept().await.unwrap(); + let mut request = [0; 4096]; + let _ = socket.read(&mut request).await.unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 30\r\nConnection: close\r\n\r\n

pinned socket fixture

", + ) + .await + .unwrap(); + }); + let mut browser = EnforcedAgentBrowser::new(Arc::new(AuthorizeExactSocket(allowed_address))) + .open("live-egress-socket-pinning"); + let launch = browser + .execute(json!({"id":"launch","action":"launch","headless":true,"args":[]})) + .await; + assert_eq!(launch["success"], true, "{launch}"); + let navigation = browser + .execute(json!({ + "id":"navigate", + "action":"navigate", + "url":format!("http://{denied_address}/") + })) + .await; + browser + .execute(json!({"id":"close","action":"close"})) + .await; + + assert_eq!(navigation["success"], true, "{navigation}"); + allowed_server.await.unwrap(); + assert_eq!( + denied.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock, + "Chromium must reach only the exact socket returned by the host grant" + ); +} + +#[tokio::test] +#[cfg(live_browser_tests)] +async fn live_chromium_uses_a_host_permitted_cross_origin_subresource_socket() { + let cdn_fixture = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let cdn_address = cdn_fixture.local_addr().unwrap(); + let page_fixture = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let page_address = page_fixture.local_addr().unwrap(); + let html = format!( + "

page fixture

", + cdn_address.port() + ); + let page_server = tokio::spawn(async move { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(10), page_fixture.accept()) + .await + .expect("Chromium requests the permitted page") + .unwrap(); + let mut request = [0; 4096]; + let _ = socket.read(&mut request).await.unwrap(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}", + html.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + let cdn_server = tokio::spawn(async move { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(10), cdn_fixture.accept()) + .await + .expect("Chromium requests the permitted cross-origin asset") + .unwrap(); + let mut request = [0; 4096]; + let _ = socket.read(&mut request).await.unwrap(); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + }); + let addresses = HashMap::from([ + ("127.0.0.1".to_owned(), page_address), + ("cdn.fixture.test".to_owned(), cdn_address), + ]); + let mut browser = EnforcedAgentBrowser::new(Arc::new(AllowFixtureHosts { + addresses, + authorizations: Arc::new(AtomicUsize::new(0)), + })) + .open("live-egress-permitted-cross-origin"); + let launch = browser + .execute(json!({"id":"launch","action":"launch","headless":true,"args":[]})) + .await; + assert_eq!(launch["success"], true, "{launch}"); + let navigation = browser + .execute(json!({ + "id":"navigate", + "action":"navigate", + "url":format!("http://127.0.0.1:{}/", page_address.port()) + })) + .await; + browser + .execute(json!({"id":"close","action":"close"})) + .await; + assert_eq!(navigation["success"], true, "{navigation}"); + page_server.await.unwrap(); + cdn_server.await.unwrap(); +} diff --git a/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/authority_denial_tests.rs b/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/authority_denial_tests.rs index d3c28a6e..28e5ee39 100644 --- a/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/authority_denial_tests.rs +++ b/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/authority_denial_tests.rs @@ -115,6 +115,7 @@ async fn live_chromium_denies_stale_egress_generation_before_dialing() -> tinybu Some(EgressHost { allowed: Arc::new(AtomicBool::new(true)), allowed_addresses: vec![allowed_address], + resolved_address: None, grant_generation: Some(40), observations: observations.clone(), }), diff --git a/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/dns_rebinding_tests.rs b/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/dns_rebinding_tests.rs new file mode 100644 index 00000000..2d944438 --- /dev/null +++ b/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/dns_rebinding_tests.rs @@ -0,0 +1,110 @@ +//! Live Chromium coverage for a destination rebound between authorizations. + +use super::*; + +#[tokio::test] +async fn live_chromium_module_never_dials_a_rebound_private_socket() -> tinybus::Result<()> { + let allowed_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let port = allowed_listener.local_addr()?.port(); + let rebound_listener = + tokio::net::TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, port)).await?; + let allowed_address = allowed_listener.local_addr()?.to_string(); + let rebound_address = rebound_listener.local_addr()?.to_string(); + assert_eq!(port, rebound_listener.local_addr()?.port()); + assert_ne!(allowed_address, rebound_address); + let extra_connections = Arc::new(AtomicUsize::new(0)); + let (served_tx, served_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(serve_fixture( + allowed_listener, + b"allowed fixture".to_vec(), + extra_connections.clone(), + served_tx, + None, + )); + let resolved_address = Arc::new(Mutex::new(allowed_address.clone())); + let browser = start_browser_with_fixture_hosts_and_resolution( + vec![allowed_address.clone()], + port, + "/before-rebind", + served_rx, + Some(resolved_address.clone()), + ) + .await?; + + let before_rebind_authorizations = browser + .observations + .lock() + .unwrap() + .iter() + .filter(|call| call.starts_with("authorize:fixture-policy:41:fixture.test:")) + .count(); + let connections_before_rebind = extra_connections.load(Ordering::SeqCst); + *resolved_address.lock().unwrap() = rebound_address.clone(); + let denied: DesktopResponse = browser + .proxy + .call( + browser_names::methods::NAVIGATE, + (serde_json::json!({ + "session": browser.session, + "url": format!("http://fixture.test:{port}/after-rebind") + }),), + ) + .await?; + + assert!(!denied.ok, "navigation to the rebound address was allowed"); + assert!( + tokio::time::timeout(Duration::from_millis(500), rebound_listener.accept()) + .await + .is_err(), + "the rebound private socket received a connection" + ); + assert!( + tokio::time::timeout(Duration::from_millis(500), async { + while extra_connections.load(Ordering::SeqCst) == connections_before_rebind { + tokio::task::yield_now().await; + } + }) + .await + .is_err(), + "the request reached the previously allowed socket after rebinding" + ); + + let calls = browser.observations.lock().unwrap().clone(); + assert!( + calls + .iter() + .filter(|call| call.starts_with("authorize:fixture-policy:41:fixture.test:")) + .count() + > before_rebind_authorizations, + "the rebound request did not reach host authorization: {calls:?}" + ); + assert!( + calls + .iter() + .any(|call| call == &format!("resolve:fixture.test:{port}:{rebound_address}")), + "the controlled resolver did not return the rebound address: {calls:?}" + ); + assert!( + !calls + .iter() + .any(|call| call == &format!("consume:{rebound_address}")), + "the rebound socket was consumed: {calls:?}" + ); + let closed: DesktopResponse = browser + .proxy + .call( + browser_names::methods::CLOSE_SESSION, + (serde_json::json!({"session": browser.session}),), + ) + .await?; + assert!( + closed.ok, + "browser session did not close: {:?}", + closed.error + ); + browser.broker_task.abort(); + let _ = browser.broker_task.await; + server.abort(); + let _ = server.await; + Ok(()) +} diff --git a/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/mod.rs b/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/mod.rs index b48fc4c3..9025ed2e 100644 --- a/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/mod.rs @@ -2,6 +2,7 @@ #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] mod authority_denial_tests; +mod dns_rebinding_tests; mod transport_classes_tests; mod worker; @@ -26,6 +27,7 @@ use tinycomputer_bus::{ struct EgressHost { allowed: Arc, allowed_addresses: Vec, + resolved_address: Option>>, grant_generation: Option, observations: Arc>>, } @@ -34,12 +36,19 @@ struct EgressHost { impl EgressHost { async fn authorize(&self, request: BrowserEgressTarget) -> tinybus::Result { tokio::task::yield_now().await; + let resolved_address = self + .resolved_address + .as_ref() + .map(|resolved| resolved.lock().unwrap().clone()); let allowed_address = self.allowed_addresses.iter().find(|address| { - address - .rsplit(':') - .next() - .and_then(|port| port.parse::().ok()) - == Some(request.port) + resolved_address + .as_ref() + .is_none_or(|resolved| *address == resolved) + && address + .rsplit(':') + .next() + .and_then(|port| port.parse::().ok()) + == Some(request.port) }); let allow = self.allowed.load(Ordering::SeqCst) && request.hostname == "fixture.test" @@ -48,6 +57,12 @@ impl EgressHost { "authorize:{}:{}:{}:{}", request.policy_id, request.expected_generation, request.hostname, request.port )); + if let Some(resolved_address) = resolved_address { + self.observations.lock().unwrap().push(format!( + "resolve:{}:{}:{}", + request.hostname, request.port, resolved_address + )); + } Ok(BrowserEgressGrant { generation: self.grant_generation.unwrap_or(request.expected_generation), authorization: if allow { @@ -166,6 +181,23 @@ async fn start_browser_with_fixture_hosts( page_port: u16, page_path: &str, served: tokio::sync::oneshot::Receiver<()>, +) -> tinybus::Result { + start_browser_with_fixture_hosts_and_resolution( + allowed_addresses, + page_port, + page_path, + served, + None, + ) + .await +} + +async fn start_browser_with_fixture_hosts_and_resolution( + allowed_addresses: Vec, + page_port: u16, + page_path: &str, + served: tokio::sync::oneshot::Receiver<()>, + resolved_address: Option>>, ) -> tinybus::Result { let bus = MemoryBus::new(); let broker = Broker::new(); @@ -179,6 +211,7 @@ async fn start_browser_with_fixture_hosts( EgressHost { allowed: allowed.clone(), allowed_addresses, + resolved_address, grant_generation: None, observations: observations.clone(), }, @@ -242,7 +275,10 @@ async fn live_chromium_module_dials_only_the_host_returned_socket_and_rejects_ch -> tinybus::Result<()> { let allowed_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let port = allowed_listener.local_addr()?.port(); - let denied_listener = tokio::net::TcpListener::bind(("127.0.0.2", port)).await?; + let denied_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let denied_address = denied_listener.local_addr()?; + let denied_port = denied_address.port(); + assert_ne!(port, denied_port); let allowed_address = allowed_listener.local_addr()?.to_string(); let extra_allowed_connections = Arc::new(AtomicUsize::new(0)); let (served_tx, served_rx) = tokio::sync::oneshot::channel(); @@ -255,9 +291,13 @@ async fn live_chromium_module_dials_only_the_host_returned_socket_and_rejects_ch None, )); - let browser = - start_browser_with_fixture_host(allowed_address.clone(), port, "/allowed", served_rx) - .await?; + let browser = start_browser_with_fixture_hosts( + vec![allowed_address.clone(), denied_address.to_string()], + port, + "/allowed", + served_rx, + ) + .await?; let authorizations_before_policy_change = browser .observations @@ -270,7 +310,7 @@ async fn live_chromium_module_dials_only_the_host_returned_socket_and_rejects_ch // Simulate a policy refresh: the host changes from granting the fixture // socket to denying requests for the same hostname. browser.allowed.store(false, Ordering::SeqCst); - let denied_url = format!("http://fixture.test:{port}/after-policy-change"); + let denied_url = format!("http://fixture.test:{denied_port}/after-policy-change"); let denied: DesktopResponse = browser .proxy .call( diff --git a/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/worker.rs b/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/worker.rs index 4d77c63e..5200a1a1 100644 --- a/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/worker.rs +++ b/crates/tinycomputer/src/tinybus_module/browser_egress_live_tests/worker.rs @@ -5,12 +5,14 @@ async fn live_chromium_module_routes_worker_egress_through_host_authorization() -> tinybus::Result<()> { let allowed_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let page_port = allowed_listener.local_addr()?.port(); - let denied_listener = tokio::net::TcpListener::bind("127.0.0.2:0").await?; - let denied_port = denied_listener.local_addr()?.port(); + let denied_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let denied_address = denied_listener.local_addr()?; + let denied_port = denied_address.port(); + assert_ne!(page_port, denied_port); let html = format!( "" ) .into_bytes(); @@ -30,7 +32,7 @@ async fn live_chromium_module_routes_worker_egress_through_host_authorization() start_browser_with_fixture_host(allowed_address, page_port, "/worker-fixture", served_rx) .await?; - let worker_authorization = format!("authorize:fixture-policy:41:127.0.0.2:{denied_port}"); + let worker_authorization = format!("authorize:fixture-policy:41:{denied_address}"); let worker_authorized = tokio::time::timeout(Duration::from_secs(10), async { loop { if browser diff --git a/scripts/test-features.sh b/scripts/test-features.sh index aac4a46d..5debf3af 100755 --- a/scripts/test-features.sh +++ b/scripts/test-features.sh @@ -13,6 +13,8 @@ case "${RUNNER_OS:-$(uname -s)}" in esac cargo test --locked --workspace cargo test --locked --workspace --all-features +RUST_TEST_THREADS=1 TINYCOMPUTER_LIVE_BROWSER_TESTS=1 cargo test --locked -p tinycomputer-browser --features agent-browser live_ +RUST_TEST_THREADS=1 TINYCOMPUTER_LIVE_BROWSER_EGRESS_TESTS=1 cargo test --locked -p tinycomputer --features static-link tinybus_module::browser_egress_live_tests -- --nocapture cargo test --locked -p tinycomputer --features static-link cargo run --locked -p tinycomputer-examples --bin basic forbidden="$(cargo tree -p tinycomputer-bus -e normal,build --prefix none | grep -Ei 'tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2|agent-desktop' || true)"