[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #54 · 2026-10-16 · Benchmark · Medium · for [web scraping](https://cdpfleet.com/web-scraping), [geo testing](https://cdpfleet.com/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](https://cdpfleet.com/docs/browsers/chromium) [Google Chrome](https://cdpfleet.com/docs/browsers/chrome) [Firefox](https://cdpfleet.com/docs/browsers/firefox) [Camoufox](https://cdpfleet.com/docs/browsers/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

| What | Why |
| --- | --- |
| `RTCPeerConnection` + `stun:stun.l.google.com:19302` | The 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.org` | The 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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/54-webrtc-ip-leak), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)

 

```node
// npm install playwright@1.60.0
// 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));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY (each setup gets its own sticky US exit from the cdpfleet residential proxy; a few KB of traffic)
import json
import os
import random
import re
import string
import time

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
# A sticky residential exit per setup: one IP for the whole session, to compare with.
RUN = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))

def sticky_exit(i):
    return f"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.
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 };
}"""

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}},
]

# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(engine, body):
    for attempt in range(1, 6):
        r = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=120)
        if r.ok:
            return r.json()
        if r.status_code != 503 or attempt == 5:
            raise RuntimeError(f"launch {r.status_code} {r.text}")
        time.sleep(int(r.headers.get("retry-after") or 5))

# Residential peers drop a connection now and then: up to two retries.
def go(page, url):
    for attempt in range(1, 4):
        try:
            return page.goto(url, timeout=60000)
        except Exception:
            if attempt == 3:
                raise
            time.sleep(2)

results = []
with sync_playwright() as p:
    for i, s in enumerate(SETUPS):
        session = launch(s["engine"], {**s["body"], "proxy": sticky_exit(i), "record": False})
        browser_type = p.firefox if s["engine"] in ("firefox", "camoufox") else p.chromium
        browser = browser_type.connect(session["wsUrl"], headers={"x-api-key": KEY})
        try:
            context = browser.new_context()
            page = context.new_page()
            go(page, "https://api.ipify.org/?format=json")
            exit_ip = json.loads(page.text_content("body"))["ip"]
            gathered = page.evaluate(GATHER)
            # candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
            parsed = [{"address": c.split(" ")[4], "type": c.split(" ")[7]} for c in gathered["candidates"]]
            public_ips = list(dict.fromkeys(c["address"] for c in parsed if re.fullmatch(r"\d+\.\d+\.\d+\.\d+", c["address"]) and c["address"] != "0.0.0.0"))
            results.append({
                "setup": s["setup"],
                "exit_ip": exit_ip,
                "webrtc_api": gathered["api"],
                "candidates": len(gathered["candidates"]),
                "mdns_host_candidates": sum(1 for c in parsed if c["type"] == "host" and c["address"].endswith(".local")),
                "public_ips": public_ips,
                "shows_exit_ip": exit_ip in public_ips,
                "leaks_other_ip": any(ip != exit_ip for ip in public_ips),
            })
        finally:
            browser.close()
print(json.dumps(results, indent=2))
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY (each setup gets its own sticky US exit from the cdpfleet residential proxy; a few KB of traffic)
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
  static final HttpClient HTTP = HttpClient.newHttpClient();

  // 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.
  static final String 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 };
      }""";

  record Setup(String name, String engine, String body) {}

  static final List<Setup> SETUPS = List.of(
      new Setup("Chromium", "chromium", "{\"headless\":\"new\"}"),
      new Setup("Google Chrome", "chrome", "{\"headless\":\"new\"}"),
      new Setup("Firefox", "firefox", "{\"headless\":true}"),
      new Setup("Camoufox (defaults: geoip on)", "camoufox", "{\"headless\":true}"),
      new Setup("Camoufox, block_webrtc: true", "camoufox", "{\"headless\":true,\"block_webrtc\":true}"));

  // A sticky residential exit per setup: one IP for the whole session, to compare with.
  static final String RUN = Long.toString(new Random().nextLong() & Long.MAX_VALUE, 36).substring(0, 6);
  static String stickyExit(int i) {
    return "cdpfleet-resi-country-us-session-w54" + RUN + i + "-lifetime-10";
  }

  // 503 means the fleet is momentarily busy: wait Retry-After and try again.
  static JsonObject launch(String engine, JsonObject body) throws Exception {
    for (int attempt = 1; ; attempt++) {
      HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
          .header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
      if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
      Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  // Residential peers drop a connection now and then: up to two retries.
  static void go(Page page, String url) throws Exception {
    for (int attempt = 1; ; attempt++) {
      try {
        page.navigate(url, new Page.NavigateOptions().setTimeout(60000));
        return;
      } catch (PlaywrightException e) {
        if (attempt == 3) throw e;
        Thread.sleep(2000);
      }
    }
  }

  public static void main(String[] args) throws Exception {
    JsonArray results = new JsonArray();
    try (Playwright pw = Playwright.create()) {
      for (int i = 0; i < SETUPS.size(); i++) {
        Setup s = SETUPS.get(i);
        JsonObject body = JsonParser.parseString(s.body()).getAsJsonObject();
        body.addProperty("proxy", stickyExit(i));
        body.addProperty("record", false);
        JsonObject session = launch(s.engine(), body);
        BrowserType type = List.of("firefox", "camoufox").contains(s.engine()) ? pw.firefox() : pw.chromium();
        Browser browser = type.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
        try {
          BrowserContext context = browser.newContext();
          Page page = context.newPage();
          go(page, "https://api.ipify.org/?format=json");
          String exitIp = JsonParser.parseString(page.textContent("body")).getAsJsonObject().get("ip").getAsString();
          JsonObject gathered = GSON.toJsonTree(page.evaluate(GATHER)).getAsJsonObject();
          JsonArray candidates = gathered.getAsJsonArray("candidates");
          Set<String> publicIps = new LinkedHashSet<>();
          int mdns = 0;
          for (JsonElement c : candidates) {
            // candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
            String[] f = c.getAsString().split(" ");
            if (f[7].equals("host") && f[4].endsWith(".local")) mdns++;
            if (f[4].matches("\\d+\\.\\d+\\.\\d+\\.\\d+") && !f[4].equals("0.0.0.0")) publicIps.add(f[4]);
          }
          JsonObject r = new JsonObject();
          r.addProperty("setup", s.name());
          r.addProperty("exit_ip", exitIp);
          r.addProperty("webrtc_api", gathered.get("api").getAsBoolean());
          r.addProperty("candidates", candidates.size());
          r.addProperty("mdns_host_candidates", mdns);
          JsonArray ips = new JsonArray();
          publicIps.forEach(ips::add);
          r.add("public_ips", ips);
          r.addProperty("shows_exit_ip", publicIps.contains(exitIp));
          r.addProperty("leaks_other_ip", publicIps.stream().anyMatch((ip) -> !ip.equals(exitIp)));
          results.add(r);
        } finally {
          browser.close();
        }
      }
    }
    System.out.println(GSON.toJson(results));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY (each setup gets its own sticky US exit from the cdpfleet residential proxy; a few KB of traffic)
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
// A sticky residential exit per setup: one IP for the whole session, to compare with.
var run = Guid.NewGuid().ToString("N")[..6];
string StickyExit(int 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 string 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 };
}";

var setups = new (string Name, string Engine, JsonObject Body)[]
{
    ("Chromium", "chromium", new JsonObject { ["headless"] = "new" }),
    ("Google Chrome", "chrome", new JsonObject { ["headless"] = "new" }),
    ("Firefox", "firefox", new JsonObject { ["headless"] = true }),
    ("Camoufox (defaults: geoip on)", "camoufox", new JsonObject { ["headless"] = true }),
    ("Camoufox, block_webrtc: true", "camoufox", new JsonObject { ["headless"] = true, ["block_webrtc"] = true }),
};

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(string engine, JsonObject body)
{
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
        if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
        if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
        await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
    }
}

// Residential peers drop a connection now and then: up to two retries.
async Task Go(IPage page, string url)
{
    for (var attempt = 1; ; attempt++)
    {
        try { await page.GotoAsync(url, new() { Timeout = 60000 }); return; }
        catch (PlaywrightException) when (attempt < 3) { await Task.Delay(2000); }
    }
}

var results = new JsonArray();
for (var i = 0; i < setups.Length; i++)
{
    var s = setups[i];
    var body = (JsonObject)s.Body.DeepClone();
    body["proxy"] = StickyExit(i);
    body["record"] = false;
    var session = await Launch(s.Engine, body);
    var type = s.Engine is "firefox" or "camoufox" ? playwright.Firefox : playwright.Chromium;
    var browser = await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
    try
    {
        var context = await browser.NewContextAsync();
        var page = await context.NewPageAsync();
        await Go(page, "https://api.ipify.org/?format=json");
        var exitIp = JsonDocument.Parse(await page.TextContentAsync("body") ?? "{}").RootElement.GetProperty("ip").GetString()!;
        var gathered = await page.EvaluateAsync<JsonElement>(Gather);
        var candidates = gathered.GetProperty("candidates").EnumerateArray().Select((c) => c.GetString()!).ToList();
        // candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
        var parsed = candidates.Select((c) => c.Split(' ')).Select((f) => (Address: f[4], Type: f[7])).ToList();
        var publicIps = parsed.Where((c) => Regex.IsMatch(c.Address, @"^\d+\.\d+\.\d+\.\d+$") && c.Address != "0.0.0.0").Select((c) => c.Address).Distinct().ToList();
        results.Add(new JsonObject
        {
            ["setup"] = s.Name,
            ["exit_ip"] = exitIp,
            ["webrtc_api"] = gathered.GetProperty("api").GetBoolean(),
            ["candidates"] = candidates.Count,
            ["mdns_host_candidates"] = parsed.Count((c) => c.Type == "host" && c.Address.EndsWith(".local")),
            ["public_ips"] = new JsonArray(publicIps.Select((ip) => (JsonNode)JsonValue.Create(ip)!).ToArray()),
            ["shows_exit_ip"] = publicIps.Contains(exitIp),
            ["leaks_other_ip"] = publicIps.Any((ip) => ip != exitIp),
        });
    }
    finally
    {
        await browser.CloseAsync();
    }
}
Console.WriteLine(results.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY (each setup gets its own sticky US exit from the cdpfleet residential proxy; a few KB of traffic)
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math/rand"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")

// 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 };
}`

type setup struct {
	Name   string
	Engine string
	Body   map[string]any
}

var setups = []setup{
	{"Chromium", "chromium", map[string]any{"headless": "new"}},
	{"Google Chrome", "chrome", map[string]any{"headless": "new"}},
	{"Firefox", "firefox", map[string]any{"headless": true}},
	{"Camoufox (defaults: geoip on)", "camoufox", map[string]any{"headless": true}},
	{"Camoufox, block_webrtc: true", "camoufox", map[string]any{"headless": true, "block_webrtc": true}},
}

type result struct {
	Setup              string   `json:"setup"`
	ExitIP             string   `json:"exit_ip"`
	WebRTCAPI          bool     `json:"webrtc_api"`
	Candidates         int      `json:"candidates"`
	MDNSHostCandidates int      `json:"mdns_host_candidates"`
	PublicIPs          []string `json:"public_ips"`
	ShowsExitIP        bool     `json:"shows_exit_ip"`
	LeaksOtherIP       bool     `json:"leaks_other_ip"`
}

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, opts map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(opts)
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
		req.Header.Set("x-api-key", key)
		req.Header.Set("content-type", "application/json")
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, err
		}
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		if res.StatusCode == 200 {
			var session map[string]any
			return session, json.Unmarshal(b, &session)
		}
		if res.StatusCode != 503 || attempt == 5 {
			return nil, fmt.Errorf("launch %d %s", res.StatusCode, strings.TrimSpace(string(b)))
		}
		wait, err := strconv.Atoi(res.Header.Get("retry-after"))
		if err != nil {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

// Residential peers drop a connection now and then: up to two retries.
func goTo(page playwright.Page, url string) error {
	for attempt := 1; ; attempt++ {
		_, err := page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
		if err == nil || attempt == 3 {
			return err
		}
		time.Sleep(2 * time.Second)
	}
}

var ipv4 = regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+$`)

func one(pw *playwright.Playwright, s setup, proxy string) (result, error) {
	out := result{Setup: s.Name, PublicIPs: []string{}}
	opts := map[string]any{"proxy": proxy, "record": false}
	for k, v := range s.Body {
		opts[k] = v
	}
	session, err := launch(s.Engine, opts)
	if err != nil {
		return out, err
	}
	browserType := pw.Chromium
	if s.Engine == "firefox" || s.Engine == "camoufox" {
		browserType = pw.Firefox
	}
	browser, err := browserType.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return out, err
	}
	defer browser.Close()
	context, err := browser.NewContext()
	if err != nil {
		return out, err
	}
	page, err := context.NewPage()
	if err != nil {
		return out, err
	}
	if err := goTo(page, "https://api.ipify.org/?format=json"); err != nil {
		return out, err
	}
	text, err := page.TextContent("body")
	if err != nil {
		return out, err
	}
	var ip struct {
		IP string `json:"ip"`
	}
	if err := json.Unmarshal([]byte(text), &ip); err != nil {
		return out, err
	}
	out.ExitIP = ip.IP
	raw, err := page.Evaluate(gather)
	if err != nil {
		return out, err
	}
	gathered := raw.(map[string]any)
	out.WebRTCAPI = gathered["api"].(bool)
	candidates, _ := gathered["candidates"].([]any)
	out.Candidates = len(candidates)
	seen := map[string]bool{}
	for _, c := range candidates {
		// candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
		f := strings.Split(c.(string), " ")
		address, typ := f[4], f[7]
		if typ == "host" && strings.HasSuffix(address, ".local") {
			out.MDNSHostCandidates++
		}
		if ipv4.MatchString(address) && address != "0.0.0.0" && !seen[address] {
			seen[address] = true
			out.PublicIPs = append(out.PublicIPs, address)
		}
	}
	for _, a := range out.PublicIPs {
		if a == out.ExitIP {
			out.ShowsExitIP = true
		} else {
			out.LeaksOtherIP = true
		}
	}
	return out, nil
}

func run() error {
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return err
	}
	defer pw.Stop()
	// A sticky residential exit per setup: one IP for the whole session, to compare with.
	run := strconv.FormatInt(rand.Int63(), 36)[:6]
	results := []result{}
	for i, s := range setups {
		r, err := one(pw, s, fmt.Sprintf("cdpfleet-resi-country-us-session-w54%s%d-lifetime-10", run, i))
		if err != nil {
			return fmt.Errorf("%s: %w", s.Name, err)
		}
		results = append(results, r)
	}
	b, _ := json.MarshalIndent(results, "", "  ")
	fmt.Println(string(b))
	return nil
}

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}
```

## What we got

| Setup | WebRTC API | Candidates | mDNS host | Public IP via WebRTC | Exit IP (HTTP) | Verdict |
| --- | --- | --- | --- | --- | --- | --- |
| Chromium | yes | 1 | 1 | — | 203.0.113.1 | nothing exposed |
| Google Chrome | yes | 1 | 1 | — | 203.0.113.2 | nothing exposed |
| Firefox | yes | 2 | 2 | — | 203.0.113.3 | nothing exposed |
| Camoufox (defaults: geoip on) | yes | 3 | 2 | 203.0.113.4 | 203.0.113.4 | same as the exit |
| Camoufox, block\_webrtc: true | no | 0 | 0 | — | 203.0.113.5 | nothing 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

-   **No setup leaked a second IP.** On cdpfleet a session that uses a proxy can't send UDP past it, so the STUN request never leaves and there's no `srflx` candidate with the server's own address.
-   **Chromium, Chrome and Firefox keep WebRTC but expose nothing:** `RTCPeerConnection` works and gathering completes with only `.local` host candidates — random mDNS names, not addresses. That's what a real visitor on a network that blocks UDP looks like.
-   **Camoufox reports the proxy's exit instead:** with its defaults (`geoip` on) the only public address in the candidates was the exit IP, so WebRTC tells the same story as HTTP. Keep the proxy sticky: the address is looked up when the browser starts, and a rotating proxy may exit elsewhere later.
-   **`block_webrtc: true` removes the API** (`RTCPeerConnection` is undefined). Nothing leaks, but a browser without WebRTC at all is unusual — keep the default unless a site or your own policy needs it gone.
-   Running the same test on your own machine behind a proxy is worth it: the `srflx` candidate there is often your real IP, unless the browser's WebRTC IP-handling policy or an extension stops it.

---
Source: https://cdpfleet.com/docs/cases/webrtc-ip-leak · Markdown: https://cdpfleet.com/docs/cases/webrtc-ip-leak.md · All docs for AI tools: https://cdpfleet.com/llms.txt
