rustenium-identity 0.1.14

A versatile stealth overlay for rustenium
Documentation
//! WebRTC — does not leak the real IP behind a proxy, and is not disabled to
//! achieve that.
//!
//! Chrome's `--proxy-server` carries TCP only, and WebRTC's UDP sockets are
//! created without consulting it, so by default STUN leaves on the host's own
//! interface: the page reads the proxy's IP from every HTTP request and the real
//! one from the server-reflexive ICE candidate. `WEBRTC_IP_POLICY_FLAG` in
//! `src/lib.rs` closes that, and is applied only when the identity carries a
//! proxy.
//!
//! The obvious objection to that flag is that a browser with no WebRTC is itself a
//! tell, which is why `webrtc_api_is_intact` exists: the policy confines candidate
//! gathering to what the proxy can carry, it does not remove the API.
//!
//! Unlike the detector tests these probe locally — the ICE candidate list is the
//! ground truth every leak-test site is reading anyway, and reading it directly
//! means the verdict does not depend on a third party's UI.
//!
//! The leak test runs both arms itself — a direct launch, then a proxied one —
//! because "no public candidate" is trivially true on a network where STUN is
//! blocked or ICE never ran. The direct arm is what makes the proxied arm
//! evidence, so it has to be the same probe on the same host, minutes apart.
//!
//! ```text
//! PROXY_URL=http://user:pass@host:port \
//!   cargo test --test webrtc -- --ignored --nocapture
//! ```

mod common;

use common::{Detector, dump};

/// Any real origin; WebRTC needs a page, not what is on it.
const PAGE: &str = "https://example.com/";

/// Gather ICE candidates the way a leak test does, and report the API surface the
/// flag is accused of removing along with them.
///
/// The gathering wait is on `icegatheringstatechange` rather than a fixed sleep,
/// with a timeout that resolves rather than rejects: gathering that never
/// completes is a result worth printing, not an error worth hiding.
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);
})()"#;

/// Whether an ICE candidate address is one that identifies the host to the page.
///
/// Chrome already replaces local addresses with an mDNS `.local` hostname, and RFC
/// 1918 / link-local space says nothing about who you are. What matters is a
/// routable address, which is what a server-reflexive candidate carries.
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];
        // fe80::/10 link-local, fc00::/7 unique local.
        return !(head & 0xffc0 == 0xfe80 || head & 0xfe00 == 0xfc00);
    }
    false
}

/// Candidates whose address is routable, as `typ protocol address`.
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()
}

/// The probe ran at all. Every assertion below is about something being *absent*,
/// which a probe that threw would satisfy just as well.
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"],
    );
}

/// The flag confines WebRTC; it does not remove it.
///
/// A browser whose `RTCPeerConnection` is missing or broken is a far louder signal
/// than an unusual candidate list — no real Chrome has one — so this is the check
/// that keeps the fix from being worse than the leak. Runs either arm: nothing
/// here depends on a proxy being configured.
#[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}"
    );
    // The codec list is the large fingerprint surface here, and it is exactly what
    // a browser with WebRTC ripped out cannot produce.
    assert!(
        probe["videoCodecs"].as_u64().unwrap_or(0) > 0,
        "RTCRtpReceiver.getCapabilities('video') returned no codecs"
    );
    assert_probe_ran(&probe);
}

/// The proxy stops a leak the same probe demonstrably shows without it.
///
/// Both arms live in one test on purpose. "No public candidate" is trivially true
/// on a network where STUN is blocked or ICE never ran, so the direct launch is not
/// background colour — it is the control that makes the proxied result evidence.
/// Split across two tests keyed on an env var, whichever arm you are not running
/// fails, and a suite that is always one red is a suite nobody reads.
///
/// The leak itself is not a fingerprint oddity: the srflx candidate carries the
/// host's real address next to a proxied HTTP request, which both identifies the
/// machine and links every session that ever ran on it.
#[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)."
    );

    // Control arm: what this host does with nothing in the way.
    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"
    );

    // Same probe, same host, with the proxy and the policy flag in place.
    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);
    // Confining WebRTC must not amount to removing it. `webrtc_api_is_intact`
    // covers the full surface; the arm carrying the flag is the one that matters.
    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"));
    // mDNS obfuscation, which is what Chrome emits for host candidates.
    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(""));
}