WebRTC Leak Test
The WebRTC local-IP leak has been largely closed since 2019. What replaced it, and why an empty candidate list is a worse signal than a full one.
Published · Updated
- Category
- Network
- API
RTCPeerConnection- Reveals
- local network interfaces, obfuscated
- Spoofing
- No practical spoof
- Since
- 2015
For years, any web page could learn your machine's local network address
without asking permission. It needed no plugin and no user gesture: build an
RTCPeerConnection, let it gather ICE candidates, and read the addresses out
of the candidate strings. VPN users were the point — your public IP said
Amsterdam while 192.168.1.24 and your real interface said otherwise.
That is the leak "WebRTC leak test" still refers to, and on a current browser
it is mostly closed. The panel above shows what your browser actually handed
over. If the raw-address count is zero and the .local count is not, you are
looking at the fix rather than the bug.
What replaced the WebRTC leak
Chromium's answer was not to stop emitting host candidates but to rename them.
Before a local candidate is signalled, it is swapped for a randomly generated
mDNS hostname — the <uuid>.local form you can see in the panel. The page gets
something structurally identical to work with, and learns nothing about your
network.
The guard is worth reading precisely, because it is narrower than people assume:
bool Port::MaybeObfuscateAddress(const Candidate& c, bool is_final) {
if (network_->GetMdnsResponder() == nullptr) {
return false;
}
if (!c.is_local()) {
return false;
}
// ... register an mDNS name, and only then add the candidateand at the call site:
bool pending = MaybeObfuscateAddress(c, is_final);
if (!pending) {
FinishAddingAddress(c, is_final);
}Two things follow. First, obfuscation applies only to local candidates and
only while an mDNS responder is attached; in either negative case the function
returns false and the address is emitted normally through
FinishAddingAddress. Second — and this is the part worth internalising — when
the responder is attached, the candidate is added only inside the name
registration callback. There is no branch that gives up and emits the raw
address instead. If the name cannot be registered, the candidate simply never
appears.
The responder is attached on the path a page takes when it has not been granted microphone or camera access. Once you grant media permission, the site has far more direct access to you than a subnet address, and the obfuscation stops applying. So the honest summary is that the leak is closed against drive-by pages, not against sites you have already trusted with your camera.
Why zero candidates is the interesting case
Because there is no raw-address fallback, anything that breaks mDNS name registration produces no host candidate at all. And mDNS registration rides multicast UDP — so a network that blocks it silently removes the candidate.
Meanwhile, ICE gathering reports itself finished without ever consulting the candidate list:
bool BasicPortAllocatorSession::CandidatesAllocationDone() const {
// Done only if all required AllocationSequence objects are created.
if (!allocation_sequences_created_) {
return false;
}
// Check that all port allocation sequences are complete (not running).
// ...
return absl::c_none_of(
ports_, [](const PortData& port) { return port.inprogress(); });
}Completion is a statement about allocation sequences and ports finishing their work. It is not a statement that anything was found. A page can therefore observe gathering complete cleanly, with an empty candidate list, and no error anywhere — which is a conspicuous state, and one people reach for as a signal.
They should not. Here are the source-confirmed ways to arrive at it:
| Cause | Real-world trigger | Produces zero? |
|---|---|---|
disable_non_proxied_udp → PORTALLOCATOR_DISABLE_UDP |
Enterprise or extension WebRTC IP policy; proxy-only setups | yes |
| Policy selects an empty network manager | default_public_interface_only, default_public_and_private, disable_non_proxied_udp |
yes |
| mDNS responder unreachable | Multicast/UDP firewalled; container with no multicast | yes |
| Candidate filter clears host candidates | Embedder policy or a narrowed iceTransportPolicy |
yes |
| ANY-bind with no resolvable default route | Adapter enumeration blocked and no default-route provider | partial |
| Genuinely empty interface list | Fully offline | yes |
| IPv6-only host with IPv6 disabled; all adapters ignore-masked | Usually degrades into the ANY-IP case | partial |
| Loopback-only host | Degrades into the ANY-IP case | partial |
getifaddrs failure |
Not reachable from a renderer — it uses an IPC network manager | n/a |
Three of those rows are partial and one cannot happen in a browser tab at all. The rest are ordinary: a corporate UDP firewall, a VPN, a privacy extension, an enterprise policy, a container. Every one of them belongs to a real person using their computer normally.
So an empty candidate list is a diagnostic, not a verdict. It tells you something about the network path. It does not tell you the visitor is automated, and shipping it as if it did means penalising exactly the privacy-conscious and corporate-managed users who are least able to do anything about it.
What this is worth as a signal
Very little, which is the honest headline for an entry whose target search is a leak test.
- Against a current browser with no media permission, the local address is an ephemeral per-origin
.localname. It is not stable, so it does not identify you across sessions. - The public IP is still visible to any server you contact, WebRTC or not. WebRTC never had to leak that; your TCP connection does.
- What remains is coarse: candidate count, address families present, whether gathering completed. A bit or so, heavily confounded.
The entry is here because "WebRTC leak test" is one of the most-run privacy checks on the web, and the accurate answer today is this was fixed, here is the mechanism, and here is why the scary-looking empty result usually is not scary.
Spoofing
There is nothing to spoof in the usual sense: a page cannot make your browser emit a different local address, and the mDNS name is regenerated per origin.
What people do instead is disable WebRTC outright, via a privacy extension or
media.peerconnection.enabled=false. That works, and it is self-defeating in
the usual way — RTCPeerConnection being absent or throwing is itself a narrow,
readable state, since almost no ordinary browser has it removed. The same trade
runs through this whole dictionary: turning a signal off replaces it with the
signal that it was turned off. See canvas for the
version of that argument where the defence adds noise instead.
References
- libwebrtc, public source:
p2p/base/port.cc(MaybeObfuscateAddress,FinishAddingAddress) andp2p/client/basic_port_allocator.cc(CandidatesAllocationDone). All code quoted above is from that tree. - webrtc:9723 — the mDNS IP-handling work referenced by the TODO in
MaybeObfuscateAddress. - MDN:
RTCPeerConnection,RTCIceCandidate.
How the probe works
This is the exact source that ran in the panel above — no summary, no drift.
// Gathers ICE candidates from an empty-iceServers peer connection and reports
// what came back. Deliberately renders no verdict: zero candidates has many
// benign causes, and the entry text lists them rather than guessing.
const GATHER_TIMEOUT_MS = 3000;
// Candidate lines look like:
// candidate:<foundation> <component> <transport> <priority> <addr> <port> typ host ...
function parseCandidate(cand) {
const parts = cand.split(" ");
const typIdx = parts.indexOf("typ");
return {
address: parts[4] ?? null,
type: typIdx >= 0 ? parts[typIdx + 1] : null,
protocol: parts[2] ?? null,
};
}
function classify(address) {
if (!address) return "none";
if (/\.local$/i.test(address)) return "mdns";
if (/^\d+\.\d+\.\d+\.\d+$/.test(address)) {
if (/^(10\.|127\.|192\.168\.|169\.254\.)/.test(address)) return "private-v4";
const m = address.match(/^172\.(\d+)\./);
if (m && +m[1] >= 16 && +m[1] <= 31) return "private-v4";
return "public-v4";
}
if (address.includes(":")) return /^(fe80|fc|fd)/i.test(address) ? "private-v6" : "public-v6";
return "other";
}
export async function probe() {
if (typeof RTCPeerConnection === "undefined") {
throw new Error("RTCPeerConnection is unavailable — WebRTC is disabled here");
}
// No STUN servers: this asks only what the machine will volunteer about
// itself. A srflx candidate cannot appear without a STUN server, so anything
// gathered here is host-derived.
const pc = new RTCPeerConnection({ iceServers: [] });
const kinds = [];
let completed = false;
try {
// A data channel is what gives the connection something to gather for,
// without asking for microphone or camera permission.
pc.createDataChannel("probe");
const done = new Promise((resolve) => {
const finish = () => resolve();
pc.addEventListener("icecandidate", (e) => {
if (!e.candidate) return finish(); // null candidate = end of gathering
kinds.push(parseCandidate(e.candidate.candidate));
});
pc.addEventListener("icegatheringstatechange", () => {
if (pc.iceGatheringState === "complete") finish();
});
setTimeout(finish, GATHER_TIMEOUT_MS);
});
await pc.setLocalDescription(await pc.createOffer());
await done;
completed = pc.iceGatheringState === "complete";
} finally {
pc.close();
}
const buckets = {};
for (const c of kinds) {
const k = classify(c.address);
buckets[k] = (buckets[k] || 0) + 1;
}
const host = kinds.filter((c) => c.type === "host").length;
const srflx = kinds.filter((c) => c.type === "srflx").length;
const mdns = kinds.filter((c) => classify(c.address) === "mdns");
const rawPrivate = kinds.filter((c) => classify(c.address).startsWith("private"));
const value = {
"Gathering state": completed ? "complete" : `still gathering after ${GATHER_TIMEOUT_MS} ms`,
"Candidates gathered": kinds.length,
"host candidates": host,
"server-reflexive (srflx)": `${srflx} (expected 0 — no STUN server was configured)`,
"Addresses obfuscated as .local": mdns.length,
"Raw private addresses exposed": rawPrivate.length,
};
if (mdns.length) {
value["Example mDNS name"] = mdns[0].address;
}
if (rawPrivate.length) {
value["Example raw address"] = rawPrivate[0].address;
}
if (Object.keys(buckets).length) {
value["Address breakdown"] = Object.entries(buckets)
.map(([k, n]) => `${k}: ${n}`)
.join(", ");
}
const label =
kinds.length === 0
? "0 candidates — see the causes table"
: rawPrivate.length
? `${rawPrivate.length} raw private address${rawPrivate.length > 1 ? "es" : ""} exposed`
: `${kinds.length} candidates, ${mdns.length} obfuscated`;
return {
// mDNS names are regenerated per session, so only the conclusion is carried.
facts: {
"Local network": kinds.length === 0
? "no candidates gathered"
: rawPrivate.length
? "raw private addresses exposed"
: "obfuscated behind .local names",
},
value,
label,
};
}