use std::time::Duration;
use browser_oxide::net::proxy::{self, ProxyAuth, ProxyConfig};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn spawn_echo_origin(banner: &'static [u8]) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let _ = sock.write_all(banner).await;
let mut buf = [0u8; 1024];
if let Ok(n) = sock.read(&mut buf).await {
let _ = sock.write_all(&buf[..n]).await;
}
}
});
port
}
async fn spawn_http_connect_proxy(expect_auth: Option<String>) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut client, _) = listener.accept().await.unwrap();
let mut head = Vec::with_capacity(512);
let mut tmp = [0u8; 256];
loop {
let n = client.read(&mut tmp).await.unwrap();
assert!(n > 0, "proxy: client closed before sending CONNECT");
head.extend_from_slice(&tmp[..n]);
if head.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
assert!(head.len() < 4096, "CONNECT request too large");
}
let head_str = String::from_utf8_lossy(&head);
let first = head_str.lines().next().unwrap();
assert!(
first.starts_with("CONNECT "),
"proxy: expected CONNECT, got {first:?}"
);
let target = first
.split_whitespace()
.nth(1)
.expect("CONNECT line missing target");
if let Some(expected) = expect_auth.as_deref() {
let got = head_str
.lines()
.find_map(|l| l.strip_prefix("Proxy-Authorization: Basic "))
.unwrap_or_else(|| {
panic!("proxy: expected Proxy-Authorization but request was: {head_str}")
});
assert_eq!(got.trim(), expected, "proxy: wrong Basic auth token");
}
let upstream = tokio::net::TcpStream::connect(target).await.unwrap();
client.write_all(b"HTTP/1.1 200 OK\r\n\r\n").await.unwrap();
let (mut cr, mut cw) = client.into_split();
let (mut ur, mut uw) = upstream.into_split();
let a = tokio::spawn(async move {
let _ = tokio::io::copy(&mut cr, &mut uw).await;
let _ = uw.shutdown().await;
});
let b = tokio::spawn(async move {
let _ = tokio::io::copy(&mut ur, &mut cw).await;
let _ = cw.shutdown().await;
});
let _ = tokio::join!(a, b);
});
port
}
#[tokio::test]
async fn http_connect_tunnels_bytes_to_origin() {
let origin_port = spawn_echo_origin(b"HELLO-FROM-ORIGIN\n").await;
let proxy_port = spawn_http_connect_proxy(None).await;
let proxy_cfg = ProxyConfig::Http {
host: "127.0.0.1".into(),
port: proxy_port,
auth: ProxyAuth::None,
tls: false,
};
let mut stream = proxy::connect(
"127.0.0.1",
origin_port,
Duration::from_secs(2),
None,
&proxy_cfg,
)
.await
.expect("proxy connect should succeed");
let mut buf = [0u8; 64];
let n = stream.read(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"HELLO-FROM-ORIGIN\n");
stream.write_all(b"PING-PAYLOAD").await.unwrap();
let n = stream.read(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"PING-PAYLOAD");
}
#[tokio::test]
async fn http_connect_sends_proxy_authorization_basic() {
use base64::Engine as _;
let origin_port = spawn_echo_origin(b"OK\n").await;
let token = base64::engine::general_purpose::STANDARD.encode("alice:s3cret!");
let proxy_port = spawn_http_connect_proxy(Some(token)).await;
let proxy_cfg = ProxyConfig::Http {
host: "127.0.0.1".into(),
port: proxy_port,
auth: ProxyAuth::UserPass("alice".into(), "s3cret!".into()),
tls: false,
};
let mut stream = proxy::connect(
"127.0.0.1",
origin_port,
Duration::from_secs(2),
None,
&proxy_cfg,
)
.await
.expect("proxy connect with auth should succeed");
let mut buf = [0u8; 8];
let n = stream.read(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"OK\n");
}
#[tokio::test]
async fn http_connect_propagates_407_denied() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut tmp = [0u8; 1024];
let _ = sock.read(&mut tmp).await;
let _ = sock
.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")
.await;
});
let proxy_cfg = ProxyConfig::Http {
host: "127.0.0.1".into(),
port: proxy_port,
auth: ProxyAuth::None,
tls: false,
};
let err = proxy::connect(
"127.0.0.1",
9, Duration::from_secs(2),
None,
&proxy_cfg,
)
.await
.expect_err("407 should produce a structured error");
let msg = format!("{err}");
assert!(
msg.contains("407"),
"expected error to mention 407, got: {msg}"
);
}
#[tokio::test]
#[ignore = "requires BROWSER_OXIDE_TEST_PROXY env var pointing at a real proxy"]
async fn live_proxy_chain() {
let proxy_url = std::env::var("BROWSER_OXIDE_TEST_PROXY")
.expect("BROWSER_OXIDE_TEST_PROXY must be set to a real proxy URL");
use browser_oxide::stealth::presets;
let mut profile_direct = presets::chrome_148_windows();
profile_direct.proxy = None;
let mut profile_proxy = profile_direct.clone();
profile_proxy.proxy = Some(proxy_url.clone());
let client_direct =
browser_oxide::net::HttpClient::new(&profile_direct).expect("direct client");
let client_proxied =
browser_oxide::net::HttpClient::new(&profile_proxy).expect("proxied client");
let url = "https://api.ipify.org/?format=text";
let resp_direct = client_direct
.get(url)
.await
.expect("direct GET should succeed");
let resp_proxied = client_proxied
.get(url)
.await
.expect("proxied GET should succeed");
assert_eq!(resp_direct.status, 200, "direct: {}", resp_direct.status);
assert_eq!(resp_proxied.status, 200, "proxied: {}", resp_proxied.status);
let ip_direct = resp_direct.text().trim().to_string();
let ip_proxied = resp_proxied.text().trim().to_string();
eprintln!("direct IP: {ip_direct}");
eprintln!("proxied IP: {ip_proxied}");
assert_ne!(
ip_direct, ip_proxied,
"proxy didn't change the egress IP — proxy is misconfigured \
or transparent (got same {ip_direct})"
);
}