cdpfleet Docs Sign up free

Examples & benchmarks / #54 · 2026-10-16 · Benchmark · Medium · for web scraping, geo testing

The WebRTC IP leak test behind a proxy: what Chromium, Chrome, Firefox and Camoufox reveal

Run the leak test fingerprinting sites use — ICE candidates against a public STUN server — on five setups behind sticky residential exits. No setup exposes a second public IP: the Chromium builds and Firefox show only an anonymous mDNS name, Camoufox reports the proxy's exit, and `block_webrtc` removes the API.

Chromium Google Chrome Firefox Camoufox

Run on production on 2026-10-16: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.

The problem

A proxy carries the browser's HTTP traffic, but WebRTC opens its own UDP connections — and a page can ask a public STUN server "which address do you see me at?" without any permission prompt. On a local browser behind a proxy or VPN, that answer is often your real IP: the classic WebRTC leak, and a standard check on fingerprinting sites. What does a page learn on the fleet, browser by browser?

What we used, and why

WhatWhy
RTCPeerConnection + stun:stun.l.google.com:19302The standard leak test: create a data channel, make an offer, collect ICE candidates. A srflx candidate is the public IP the STUN server saw; host candidates are local addresses (browsers hide them behind random .local names).
https://api.ipify.orgThe exit IP the site sees over HTTP, through the proxy — what any WebRTC address is compared with.
cdpfleet-resi-country-us-session-…A sticky residential exit per setup: one IP for the whole session, so "the same IP as the exit" is a fair comparison.
block_webrtc: true (Camoufox)Turns WebRTC off in the engine.
geoip (Camoufox, on by default)Matches location to the proxy — and makes WebRTC report the proxy's exit IP.

How it works

  1. For each setup, launch a session with its own sticky exit and read the exit IP from ipify.
  2. Run the leak test in the page and parse every ICE candidate: its address and type.
  3. Compare any public address with the exit IP: the same, different (a leak) or none.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY (each setup gets its own sticky US exit from the cdpfleet residential proxy; a few KB of traffic)
import { chromium, firefox } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
// A sticky residential exit per setup: one IP for the whole session, to compare with.
const RUN = Math.random().toString(36).slice(2, 8);
const stickyExit = (i) => `cdpfleet-resi-country-us-session-w54${RUN}${i}-lifetime-10`;

// What a WebRTC leak test runs: gather ICE candidates against a public STUN server. A "srflx"
// candidate carries the public IP the STUN server saw; UDP doesn't go through an HTTP/SOCKS proxy —
// on cdpfleet, proxied sessions can't send UDP at all, so only an anonymous mDNS host candidate is left.
const GATHER = `async () => {
  if (typeof RTCPeerConnection === 'undefined') return { api: false, candidates: [] };
  const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
  const candidates = [];
  pc.onicecandidate = (e) => { if (e.candidate && e.candidate.candidate) candidates.push(e.candidate.candidate); };
  pc.createDataChannel('probe');
  await pc.setLocalDescription(await pc.createOffer());
  await new Promise((done) => {
    const timer = setTimeout(done, 8000);
    pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(timer); done(); } };
  });
  pc.close();
  return { api: true, candidates };
}`;

const SETUPS = [
  { setup: 'Chromium', engine: 'chromium', body: { headless: 'new' } },
  { setup: 'Google Chrome', engine: 'chrome', body: { headless: 'new' } },
  { setup: 'Firefox', engine: 'firefox', body: { headless: true } },
  { setup: 'Camoufox (defaults: geoip on)', engine: 'camoufox', body: { headless: true } },
  { setup: 'Camoufox, block_webrtc: true', engine: 'camoufox', body: { headless: true, block_webrtc: true } },
];
const FIREFOX_FAMILY = ['firefox', 'camoufox'];

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status !== 503 || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
    await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
  }
}

// Residential peers drop a connection now and then: up to two retries.
async function go(page, url) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await page.goto(url, { timeout: 60000 });
    } catch (err) {
      if (attempt === 3) throw err;
      await new Promise((r) => setTimeout(r, 2000));
    }
  }
}

const results = [];
for (const [i, s] of SETUPS.entries()) {
  const session = await launch(s.engine, { ...s.body, proxy: stickyExit(i), record: false });
  const browser = await (FIREFOX_FAMILY.includes(s.engine) ? firefox : chromium).connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const context = await browser.newContext();
    const page = await context.newPage();
    await go(page, 'https://api.ipify.org/?format=json');
    const exitIp = JSON.parse(await page.textContent('body')).ip;
    const { api, candidates } = await page.evaluate(`(${GATHER})()`);
    // candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
    const parsed = candidates.map((c) => { const f = c.split(' '); return { address: f[4], type: f[7] }; });
    const publicIps = [...new Set(parsed.filter((c) => /^\d+\.\d+\.\d+\.\d+$/.test(c.address) && c.address !== '0.0.0.0').map((c) => c.address))];
    results.push({
      setup: s.setup,
      exit_ip: exitIp,
      webrtc_api: api,
      candidates: candidates.length,
      mdns_host_candidates: parsed.filter((c) => c.type === 'host' && c.address.endsWith('.local')).length,
      public_ips: publicIps,
      shows_exit_ip: publicIps.includes(exitIp),
      leaks_other_ip: publicIps.some((ip) => ip !== exitIp),
    });
  } finally {
    await browser.close();
  }
}
console.log(JSON.stringify(results, null, 2));

What we got

SetupWebRTC APICandidatesmDNS hostPublic IP via WebRTCExit IP (HTTP)Verdict
Chromiumyes11—203.0.113.1nothing exposed
Google Chromeyes11—203.0.113.2nothing exposed
Firefoxyes22—203.0.113.3nothing exposed
Camoufox (defaults: geoip on)yes32203.0.113.4203.0.113.4same as the exit
Camoufox, block_webrtc: trueno00—203.0.113.5nothing exposed

From the Node.js run on 2026-10-16. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
[
  {
    "setup": "Chromium",
    "exit_ip": "203.0.113.1",
    "webrtc_api": true,
    "candidates": 1,
    "mdns_host_candidates": 1,
    "public_ips": [],
    "shows_exit_ip": false,
    "leaks_other_ip": false
  },
  {
    "setup": "Google Chrome",
    "exit_ip": "203.0.113.2",
    "webrtc_api": true,
    "candidates": 1,
    "mdns_host_candidates": 1,
    "public_ips": [],
    "shows_exit_ip": false,
    "leaks_other_ip": false
  },
  {
    "setup": "Firefox",
    "exit_ip": "203.0.113.3",
    "webrtc_api": true,
    "candidates": 2,
    "mdns_host_candidates": 2,
    "public_ips": [],
    "shows_exit_ip": false,
    "leaks_other_ip": false
  },
  {
    "setup": "Camoufox (defaults: geoip on)",
    "exit_ip": "203.0.113.4",
    "webrtc_api": true,
    "candidates": 3,
    "mdns_host_candidates": 2,
    "public_ips": [
      "203.0.113.4"
    ],
    "shows_exit_ip": true,
    "leaks_other_ip": false
  },
  {
    "setup": "Camoufox, block_webrtc: true",
    "exit_ip": "203.0.113.5",
    "webrtc_api": false,
    "candidates": 0,
    "mdns_host_candidates": 0,
    "public_ips": [],
    "shows_exit_ip": false,
    "leaks_other_ip": false
  }
]

Takeaways