← dictionary
Network

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.

Category
Network
API
RTCPeerConnection
Reveals
local network interfaces, obfuscated
Spoofing
No practical spoof
Since
2015
Liverunning the probe…

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:

cpp
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 candidate

and at the call site:

cpp
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:

cpp
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_udpPORTALLOCATOR_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 .local name. 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) and p2p/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.

JavaScript
// 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,
  };
}