cdpfleet Docs GitHub Dashboard

Cases / #54 · 2026-10-16 · Medium

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

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