Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d95dbeb
chore(vendor): bump agent-browser, agent-desktop, tinybus and tinyinf…
senamakel Oct 22, 2026
ef60efd
chore(deps): refresh lockfile and vendor tinybus
senamakel Oct 22, 2026
4d771a9
Merge remote-tracking branch 'upstream/pr-152' into epic-7328-111-bro…
senamakel Oct 22, 2026
f7a1e69
test(browser): cover worker fetch egress denial in live tests
senamakel Oct 22, 2026
ce5180a
test: run live browser tests in feature test script
senamakel Oct 22, 2026
9620710
docs(test): fix live browser test command filter
senamakel Oct 22, 2026
b75b60b
test(browser): cover DNS rebinding and allowed cross-origin egress
senamakel Oct 22, 2026
9a54fbc
test(browser): stabilize worker egress fixtures
senamakel Oct 22, 2026
d0ecf6d
chore(deps): bump vendored tinybus to 0.1.9
senamakel Oct 22, 2026
cf5d861
chore(deps): pin agent-browser zip fix
senamakel Oct 22, 2026
c5cd60b
chore(deps): update lockfile dependencies
senamakel Oct 22, 2026
26b4676
chore(deps): pin agent-desktop trace sink
senamakel Oct 22, 2026
d7dcfd5
chore(vendor): bump tinyinference submodule
senamakel Oct 22, 2026
9bd5582
chore(deps): bump workspace crates to 0.10.1 and refresh lockfile
senamakel Oct 22, 2026
6bb1b30
test(browser): bind rebinding target to the allowed port
senamakel Oct 22, 2026
05ee0e6
Merge pull request #16 from senamakel/epic-7328-111-browser-egress
senamakel Oct 22, 2026
bbb0e85
merge upstream main and resolve browser egress test conflicts
senamakel Oct 23, 2026
f2157cc
test: add live browser egress test run to feature script
senamakel Oct 23, 2026
c1467dc
test(browser): clone observed addresses before asserting
senamakel Oct 23, 2026
7c80018
test(browser): use to_string for static worker fixture html
senamakel Oct 23, 2026
c38ef88
test(browser): bind denied listeners on distinct ephemeral ports
senamakel Oct 23, 2026
f94c539
test(browser): authorize denied host in live egress tests
senamakel Oct 23, 2026
ce5b656
test(browser_egress): clone observations to release the lock earlier
senamakel Oct 23, 2026
9e0d7fb
test(browser): split live egress tests into focused modules
senamakel Oct 23, 2026
6267be3
test(browser_egress): bind rebound listener to same port on ipv6
senamakel Oct 23, 2026
8b5eb8e
style: wrap long listener bind line in dns rebinding test
senamakel Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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!(
"<img src=\"http://127.0.0.1:{}/private.png\">",
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<h1>proxy fixture</h1>",
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;
Expand All @@ -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<h1>pinned socket fixture</h1>",
)
.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!(
"<img src=\"http://127.0.0.1:{}/private.png\">",
let html = "<script>new Worker('/worker.js')</script><p>worker fixture</p>".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;
Expand All @@ -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"
);
}

Expand Down Expand Up @@ -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<AtomicUsize>,
denied_targets: Arc<Mutex<Vec<(String, u16)>>>,
}

#[cfg(live_browser_tests)]
#[derive(Debug)]
struct AuthorizeExactSocket(SocketAddr);

#[cfg(live_browser_tests)]
impl EgressAuthorizer for AuthorizeExactSocket {
fn authorize(
&self,
_: BrowserDialTarget,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = io::Result<DialGrant>> + 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<Box<dyn std::future::Future<Output = io::Result<()>> + 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<Box<dyn std::future::Future<Output = io::Result<bool>> + Send>> {
Box::pin(async { Ok(true) })
}
}

#[cfg(live_browser_tests)]
async fn wait_for_denied_target(denied_targets: &Arc<Mutex<Vec<(String, u16)>>>, 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<Box<dyn std::future::Future<Output = io::Result<DialGrant>> + 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<Box<dyn std::future::Future<Output = io::Result<()>> + 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<Box<dyn std::future::Future<Output = io::Result<bool>> + Send>> {
Box::pin(async { Ok(true) })
}
}
Loading
Loading