mod common;
use common::{Detector, dump};
const PAGE: &str = "https://example.com/";
const PROBE: &str = r#"(async () => {
const out = {
error: null,
hasRTCPeerConnection: typeof RTCPeerConnection === 'function',
ctorSource: '',
videoCodecs: 0,
gathering: '',
candidates: [],
};
try {
out.ctorSource = String(RTCPeerConnection);
const caps = (window.RTCRtpReceiver && RTCRtpReceiver.getCapabilities)
? RTCRtpReceiver.getCapabilities('video') : null;
out.videoCodecs = (caps && caps.codecs) ? caps.codecs.length : 0;
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
const raw = [];
pc.addEventListener('icecandidate', (e) => {
if (e.candidate && e.candidate.candidate) raw.push(e.candidate.candidate);
});
pc.createDataChannel('probe');
await pc.setLocalDescription(await pc.createOffer());
await new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') return resolve();
const timer = setTimeout(resolve, 20000);
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') { clearTimeout(timer); resolve(); }
});
});
// Read the state before close(); closing moves it to 'closed'.
out.gathering = pc.iceGatheringState;
out.candidates = raw.map((c) => {
const p = c.split(' ');
return { protocol: p[2], address: p[4], typ: p[7], raw: c };
});
pc.close();
} catch (e) {
out.error = String(e);
}
return JSON.stringify(out);
})()"#;
fn is_public_address(address: &str) -> bool {
let a = address.trim();
if a.is_empty() || a.ends_with(".local") {
return false;
}
if let Ok(v4) = a.parse::<std::net::Ipv4Addr>() {
return !(v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation());
}
if let Ok(v6) = a.parse::<std::net::Ipv6Addr>() {
if v6.is_loopback() || v6.is_unspecified() {
return false;
}
let head = v6.segments()[0];
return !(head & 0xffc0 == 0xfe80 || head & 0xfe00 == 0xfc00);
}
false
}
fn public_candidates(probe: &serde_json::Value) -> Vec<String> {
probe["candidates"]
.as_array()
.into_iter()
.flatten()
.filter(|c| is_public_address(c["address"].as_str().unwrap_or_default()))
.map(|c| {
format!(
"{} {} {}",
c["typ"].as_str().unwrap_or("?"),
c["protocol"].as_str().unwrap_or("?"),
c["address"].as_str().unwrap_or("?"),
)
})
.collect()
}
fn assert_probe_ran(probe: &serde_json::Value) {
assert!(
probe["error"].is_null(),
"the WebRTC probe threw ({}), so nothing below was measured",
probe["error"]
);
assert_eq!(
probe["gathering"].as_str(),
Some("complete"),
"ICE gathering never completed (state {}); a candidate list read before \
gathering finished proves nothing about what it would contain",
probe["gathering"],
);
}
#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn webrtc_api_is_intact() {
let mut d = Detector::open(1, PAGE).await;
let probe = d.eval_async(PROBE).await;
dump("webrtc probe", &probe);
d.close().await;
assert_eq!(
probe["hasRTCPeerConnection"].as_bool(),
Some(true),
"RTCPeerConnection is missing — on a Chrome UA that is a one-line detection"
);
let source = probe["ctorSource"].as_str().unwrap_or_default();
assert!(
source.contains("[native code]"),
"RTCPeerConnection no longer reports as native: {source}"
);
assert!(
probe["videoCodecs"].as_u64().unwrap_or(0) > 0,
"RTCRtpReceiver.getCapabilities('video') returned no codecs"
);
assert_probe_ran(&probe);
}
#[tokio::test]
#[ignore = "launches two browsers and hits the network, and needs a proxy"]
async fn the_proxy_stops_the_leak_a_direct_connection_shows() {
assert!(
std::env::var("NO_IDENTITY").is_err(),
"the policy flag is applied by IdentitySession::launch; the baseline arm \
cannot exercise it"
);
assert!(
std::env::var("PROXY_URL").is_ok() || std::env::var("IDENTITY_JSON").is_ok(),
"no proxy configured, and the flag under test is only applied alongside \
one. Run with PROXY_URL=http://user:pass@host:port (or an IDENTITY_JSON \
that carries a proxy)."
);
let mut d = Detector::open_direct(1, PAGE).await;
let direct = d.eval_async(PROBE).await;
dump("webrtc probe (direct)", &direct);
d.close().await;
assert_probe_ran(&direct);
let leaked = public_candidates(&direct);
println!("direct: {leaked:?}");
assert!(
!leaked.is_empty(),
"no public candidate on a direct connection — STUN is blocked or ICE did \
not run here, so this network cannot demonstrate the leak, and the \
proxied arm below cannot demonstrate its absence"
);
let mut d = Detector::open(1, PAGE).await;
let proxy = d.identity().proxy.clone().unwrap_or_default();
let proxied = d.eval_async(PROBE).await;
dump("webrtc probe (proxied)", &proxied);
d.close().await;
assert!(
!proxy.is_empty(),
"the identity carries no proxy, so no policy flag was applied and this run \
proves nothing"
);
assert_probe_ran(&proxied);
assert_eq!(
proxied["hasRTCPeerConnection"].as_bool(),
Some(true),
"the policy flag took RTCPeerConnection with it"
);
let public = public_candidates(&proxied);
assert!(
public.is_empty(),
"WebRTC leaked routable address(es) past the proxy: {public:?}. Every HTTP \
request in this session carried the proxy's IP instead."
);
}
#[test]
fn classifies_candidate_addresses() {
assert!(is_public_address("93.184.216.34"));
assert!(is_public_address("2606:2800:220:1:248:1893:25c8:1946"));
assert!(!is_public_address("4f9a1f2e-6b7c-4a1d-9f3e-0a1b2c3d4e5f.local"));
assert!(!is_public_address("192.168.1.7"));
assert!(!is_public_address("10.0.0.4"));
assert!(!is_public_address("172.20.1.1"));
assert!(!is_public_address("169.254.10.1"));
assert!(!is_public_address("127.0.0.1"));
assert!(!is_public_address("fe80::1"));
assert!(!is_public_address("fd12:3456::1"));
assert!(!is_public_address(""));
}