use std::time::Duration;
use runtime_foxdriver::{launch_firefox, FoxBrowserConfig, Page, ProxyConfig};
const STUN_URL: &str = "stun:stun.l.google.com:19302";
fn firefox_present() -> bool {
std::process::Command::new("firefox")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn stun_reachable() -> bool {
use std::net::UdpSocket;
let Ok(sock) = UdpSocket::bind("0.0.0.0:0") else {
return false;
};
let _ = sock.set_read_timeout(Some(Duration::from_secs(3)));
let mut msg = vec![0x00, 0x01, 0x00, 0x00, 0x21, 0x12, 0xA4, 0x42];
msg.extend_from_slice(&[0xAB; 12]);
let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&("stun.l.google.com", 19302)) else {
return false;
};
let Some(addr) = addrs.into_iter().next() else {
return false;
};
if sock.send_to(&msg, addr).is_err() {
return false;
}
let mut buf = [0u8; 256];
matches!(sock.recv_from(&mut buf), Ok((n, _)) if n >= 20)
}
async fn gather_ice_candidates(page: &Page) -> Vec<String> {
let setup = format!(
r#"(() => {{
window.__cands = [];
window.__gatherDone = false;
let pc;
try {{
pc = new RTCPeerConnection({{ iceServers: [{{ urls: '{STUN_URL}' }}] }});
}} catch (e) {{ window.__gatherErr = String(e); return; }}
pc.onicecandidate = (e) => {{
if (e.candidate) window.__cands.push(e.candidate.candidate);
else window.__gatherDone = true;
}};
pc.createDataChannel('probe');
pc.createOffer().then((o) => pc.setLocalDescription(o)).catch((e) => {{ window.__gatherErr = String(e); }});
}})()"#
);
let _ = page.evaluate(&setup).await;
for _ in 0..40 {
tokio::time::sleep(Duration::from_millis(150)).await;
let done = page
.evaluate("window.__gatherDone === true")
.await
.ok()
.and_then(|v| v.into_value::<bool>().ok())
.unwrap_or(false);
if done {
break;
}
}
page.evaluate("JSON.stringify(window.__cands || [])")
.await
.ok()
.and_then(|v| v.into_value::<String>().ok())
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default()
}
async fn launch(config: FoxBrowserConfig) -> Page {
let page = launch_firefox(config).await.expect("launch firefox");
page.goto("about:blank").await.expect("goto about:blank");
page
}
async fn run_baseline_leaks() {
let page = launch(FoxBrowserConfig {
headless: true,
viewport_width: 800,
viewport_height: 600,
..Default::default()
})
.await;
let cands = gather_ice_candidates(&page).await;
let _ = page.close().await;
let has_srflx = cands.iter().any(|c| c.contains("typ srflx"));
assert!(
has_srflx,
"BASELINE (no proxy): expected an srflx candidate proving the real public \
IP escapes via WebRTC, got {} candidate(s) with none srflx. (If this \
flakes, STUN egress from Firefox differs from the precheck.)",
cands.len()
);
}
async fn run_proxy_closes_leak() {
let page = launch(FoxBrowserConfig {
headless: true,
viewport_width: 800,
viewport_height: 600,
profile_dir: Some(
std::env::temp_dir()
.join(format!("fox-webrtc-leak-{}", std::process::id()))
.to_string_lossy()
.into_owned(),
),
proxy: Some(ProxyConfig::from_url("socks5://127.0.0.1:1").unwrap()),
..Default::default()
})
.await;
let cands = gather_ice_candidates(&page).await;
let _ = page.close().await;
let leaked: Vec<&String> = cands.iter().filter(|c| c.contains("typ srflx")).collect();
assert!(
leaked.is_empty(),
"FIXED (proxy + ice.proxy_only): WebRTC must gather NO srflx candidate. \
the real public IP must not escape the proxy. Leaked: {leaked:?}"
);
}
static BROWSER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn webrtc_srflx_leak_present_without_proxy() {
if !firefox_present() {
eprintln!("SKIP webrtc_srflx_leak_present_without_proxy: firefox not on PATH");
return;
}
if !stun_reachable() {
eprintln!("SKIP webrtc_srflx_leak_present_without_proxy: STUN egress blocked");
return;
}
let _serial = BROWSER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(run_baseline_leaks());
}
#[test]
fn webrtc_proxy_only_closes_the_srflx_leak() {
if !firefox_present() {
eprintln!("SKIP webrtc_proxy_only_closes_the_srflx_leak: firefox not on PATH");
return;
}
if !stun_reachable() {
eprintln!("SKIP webrtc_proxy_only_closes_the_srflx_leak: STUN egress blocked");
return;
}
let _serial = BROWSER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(run_proxy_closes_leak());
}