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
| 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
- For each setup, launch a session with its own sticky exit and read the exit IP from ipify.
- Run the leak test in the page and parse every ICE candidate: its address and type.
- 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:
CDPFLEET_API_KEY— your API key (dashboard → API keys)
// 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));
# 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))
// 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));
}
}
// 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 get github.com/playwright-community/[email protected]
// 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
srflxcandidate with the server's own address. - Chromium, Chrome and Firefox keep WebRTC but expose nothing:
RTCPeerConnectionworks and gathering completes with only.localhost 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 (
geoipon) 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: trueremoves the API (RTCPeerConnectionis 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
srflxcandidate there is often your real IP, unless the browser's WebRTC IP-handling policy or an extension stops it.