Two APIs That Publish Your Network

The TCP peer a server sees is whatever the proxy chain presents. But a page can also ask the browser directly, and two APIs will tell it: WebRTC, which can open a direct UDP path to a STUN server and report the public address it saw, and the Network Information API, which reports the quality of the connection underneath.

Both matter to a scraper for the same reason: a residential forward proxy hides your TCP peer, but neither of these APIs travels through the proxy. A page that runs a WebRTC probe can learn the machine's real public IP while every HTTP request appears to come from the proxy. The mismatch between what the site sees at the TCP layer and what the page reports is the leak.

The WebRTC Stack

RTCPeerConnection implements WebRTC. The part relevant here is ICE, the connectivity establishment protocol: the peer connection gathers candidates, which are the addresses at which it might be reachable, and tries them in priority order.

Candidate type Address it reports Who learns it
host every local interface: 192.168.1.44, 10.0.0.7, 169.254.x.x, and IPv6 link-local the page, directly
server-reflexive (srflx) the public address a STUN server saw the packet arrive from the page, via a STUN round trip
relay the address of a TURN relay the client was allocated the page, via a TURN allocation

The candidate line is a fixed format, which makes it easy to read and easy to fake:

candidate:1 1 udp 2130706431 192.168.1.44 54321 typ host generation 0
candidate:4 1 udp 1677724915 203.0.113.84 41003 typ srflx raddr 192.168.1.44 rport 54321

Fields in order: the candidate: prefix, a foundation, the component (1 for RTP, 2 for RTCP), the protocol, a priority, the address, the port, typ and the type, then optional raddr/rport pointing back at the local address. The second field after the type in a real srflx line is the related address, which is the private host address the STUN response came from.

mDNS Obfuscation, and Why It Is Its Own Fingerprint

Chrome and Safari do not hand a page the raw host address by default. They substitute a .local hostname derived from a hash of the real IP, in the UUID form, and the page cannot recover the address from it:

candidate:3 1 udp 847249151 a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d.local 54322 typ host

This is a real privacy improvement: the page sees an opaque name instead of your LAN address. But it is stable per interface per origin, so it replaces one identifier with another. The hash is derived deterministically from the local IP with a per-origin salt, so the same machine produces the same mDNS name to the same site, and different sites see different names. A collector that sees the same mDNS name across visits has an identifier that survives cookie clearing.

Firefox does not do this by default and does expose real host addresses unless the relevant preference is set, which is another platform difference a server can read.

Gathering Needs No Permission

This is the part that surprises people. A page does not need a camera, a microphone, or any permission to make ICE gather:

const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
pc.createDataChannel("probe");                 // no getUserMedia, no prompt
pc.onicecandidate = (e) => { if (e.candidate) collected.push(e.candidate.candidate); };
pc.createOffer().then((o) => pc.setLocalDescription(o));

createDataChannel gives the connection a reason to gather candidates, and the STUN server in iceServers gives it a reason to send a packet outside the page. No permission prompt appears, and nothing in the UI tells the user. Firefox and Safari have shipped mitigations (a visible indicator, and in some configurations no srflx candidate without a permission), but the host candidates still gather, and the mDNS substitution means the shape of the leak survives even where the address does not.

The timing of the candidates is a further signal. Host candidates appear within a millisecond or two of setLocalDescription; a server-reflexive candidate appears only after a STUN round trip completes, which depends on the path; a relay candidate appears after a TURN allocation, which involves authentication and can take hundreds of milliseconds. The intervals between them are a behavioural profile, and a client that produces all three at once, or none at all, is unusual.

What a WAF Learns From a Probe

If a scraped page runs a WebRTC probe, the site receives whatever the page sends back. The candidate list is a compact description of the visitor's network topology: how many interfaces the machine has, whether it is behind NAT, whether it is on a LAN, what its public address is, and whether a TURN relay is in use.

The specific leak that matters for a proxied scraper is the srflx candidate. A forward proxy is a TCP proxy: the browser connects to the proxy and the proxy connects to the site. The STUN packet, however, goes out over UDP from the machine, directly, to the STUN server, bypassing the proxy entirely. The public address that comes back is the machine's own, and the page can post it anywhere. So a residential proxy that successfully hides the TCP peer is undone by one line of JavaScript.

The parser below reads a plausible candidate set and reports exactly what it exposes.

# Parse a plausible RTCPeerConnection ICE candidate set and report what it leaks.
#
# The JSON below is the shape a page gets from `pc.onicecandidate`, collected
# without any media permission, then JSON.stringify'd.

import ipaddress
import json
import re

# What a real browser puts on the wire, gathered from one pc.createDataChannel
# call on a consumer laptop and one on an iPhone. A residential forward proxy is
# assumed in both cases, so the TCP peer is the proxy.
CANDIDATES = (
    '['
    '{"candidate": "candidate:1 1 udp 2130706431 192.168.1.44 54321 typ host generation 0",'
    '"sdpMid": "0", "sdpMLineIndex": 0, "usernameFragment": "8Yc1"},'
    '{"candidate": "candidate:2 1 udp 1694498815 10.0.0.7 62000 typ host generation 0",'
    '"sdpMid": "0", "sdpMLineIndex": 0, "usernameFragment": "8Yc1"},'
    '{"candidate": "candidate:3 1 udp 847249151 a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d.local 54322 typ host generation 0",'
    '"sdpMid": "0", "sdpMLineIndex": 0, "usernameFragment": "8Yc1"},'
    '{"candidate": "candidate:4 1 udp 1677724915 203.0.113.84 41003 typ srflx raddr 192.168.1.44 rport 54321 generation 0",'
    '"sdpMid": "0", "sdpMLineIndex": 0, "usernameFragment": "8Yc1"},'
    '{"candidate": "candidate:5 1 udp 4188578811 198.51.100.9 51999 typ relay raddr 0.0.0.0 rport 0 generation 0",'
    '"sdpMid": "0", "sdpMLineIndex": 0, "usernameFragment": "8Yc1"}'
    ']'
)

# A page that has been hardened against WebRTC leaks: only relay candidates
# survive, or the object is empty because the API was removed.
HARDENED_RELAY_ONLY = (
    '[{"candidate": "candidate:5 1 udp 4188578811 198.51.100.9 51999 typ relay raddr 0.0.0.0 rport 0",'
    '"sdpMid": "0", "sdpMLineIndex": 0}]'
)

HARDENED_EMPTY = "[]"

MDNS = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.local$",
                  re.IGNORECASE)


# RFC 5737 documentation ranges. is_private covers these too, which is why they
# are separated out: on a real capture these slots hold the visitor's address.
DOCUMENTATION = (ipaddress.ip_network("192.0.2.0/24"),
                  ipaddress.ip_network("198.51.100.0/24"),
                  ipaddress.ip_network("203.0.113.0/24"))


def classify(address):
    # One word for what this address reveals.
    if MDNS.match(address):
        return "mdns", "obfuscated by the browser, a stable hash of the interface"
    try:
        ip = ipaddress.ip_address(address)
    except ValueError:
        return "unparsed", "not an IP address at all"
    if ip.is_loopback:
        return "loopback", "the machine itself"
    if ip.is_link_local:
        return "link-local", "the local segment only"
    if any(ip in net for net in DOCUMENTATION):
        return "public", "a public address, written here as a documentation range"
    if ip.is_private:
        return "private", "the LAN address of the machine"
    return "public", "the address the internet sees"


def parse(candidate_line):
    # candidate:<foundation> <component> <proto> <priority> <addr> <port> typ <type> ...
    parts = candidate_line.split()
    if not parts[0].startswith("candidate:") or len(parts) < 8 or parts[6] != "typ":
        return None
    fields = {}
    for token in parts[8:]:
        if "=" in token:
            key, value = token.split("=", 1)
            fields[key] = value
    return {
        "foundation": parts[0].split(":", 1)[1],
        "component": parts[1],
        "proto": parts[2],
        "priority": int(parts[3]),
        "address": parts[4],
        "port": int(parts[5]),
        "type": parts[7],
        "raddr": fields.get("raddr"),
    }


def report(label, raw):
    entries = json.loads(raw)
    print(f"=== {label} ===")
    print(f"{len(entries)} candidates")
    if not entries:
        print("  nothing gathered: the probe got no ICE candidates back\n")
        return {"public": 0, "private": 0, "mdns": 0, "relay": 0, "srflx": 0}

    tally = {"public": 0, "private": 0, "mdns": 0, "relay": 0, "srflx": 0}
    for entry in entries:
        parsed = parse(entry["candidate"])
        if parsed is None:
            print("  unparseable:", entry["candidate"])
            continue
        kind, note = classify(parsed["address"])
        if kind == "private":
            tally["private"] += 1
        elif kind == "mdns":
            tally["mdns"] += 1
        elif kind == "public":
            tally["public"] += 1
        if parsed["type"] == "relay":
            tally["relay"] += 1
        elif parsed["type"] == "srflx":
            tally["srflx"] += 1
        print(f"  {parsed['type']:6} {parsed['address']:42} :{parsed['port']:<6} "
              f"{kind:9} {note}")

    leaks = []
    if tally["public"] and tally["srflx"]:
        leaks.append("public address, reachable without the proxy: the exit IP is exposed")
    if tally["private"]:
        leaks.append("LAN address: the machine is on a known private range")
    if tally["mdns"]:
        leaks.append("mDNS hostname: a hash of the interface, stable per device")
    if tally["relay"]:
        leaks.append("relay candidate: only a TURN server's address is public")
    print(f"  public={tally['public']} private={tally['private']} "
          f"mdns={tally['mdns']} srflx={tally['srflx']} relay={tally['relay']}")
    for leak in leaks:
        print(f"  leak: {leak}")
    print()
    return tally


def main():
    raw = report("stock Chrome, residential TCP proxy in front", CANDIDATES)
    report("relay candidates only, the usual hardening", HARDENED_RELAY_ONLY)
    report("WebRTC removed from the page entirely", HARDENED_EMPTY)

    print("what each hardening actually hides:")
    print(f"  full ICE set leaks a public srflx address: "
          f"{'yes' if raw['srflx'] else 'no'}")
    print("  relay-only hides the client's own address, but the STUN query still")
    print("  left the machine, and the relay address is still a unique session key")
    print("  removing the API hides everything, and also removes a real feature: a")
    print("  page can no longer detect loss of connectivity or switch network path")


main()
=== stock Chrome, residential TCP proxy in front ===
5 candidates
  host   192.168.1.44                               :54321  private   the LAN address of the machine
  host   10.0.0.7                                   :62000  private   the LAN address of the machine
  host   a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d.local :54322  mdns      obfuscated by the browser, a stable hash of the interface
  srflx  203.0.113.84                               :41003  public    a public address, written here as a documentation range
  relay  198.51.100.9                               :51999  public    a public address, written here as a documentation range
  public=2 private=2 mdns=1 srflx=1 relay=1
  leak: public address, reachable without the proxy: the exit IP is exposed
  leak: LAN address: the machine is on a known private range
  leak: mDNS hostname: a hash of the interface, stable per device
  leak: relay candidate: only a TURN server's address is public

=== relay candidates only, the usual hardening ===
1 candidates
  relay  198.51.100.9                               :51999  public    a public address, written here as a documentation range
  public=1 private=0 mdns=0 srflx=0 relay=1
  leak: relay candidate: only a TURN server's address is public

=== WebRTC removed from the page entirely ===
0 candidates
  nothing gathered: the probe got no ICE candidates back

what each hardening actually hides:
  full ICE set leaks a public srflx address: yes
  relay-only hides the client's own address, but the STUN query still
  left the machine, and the relay address is still a unique session key
  removing the API hides everything, and also removes a real feature: a
  page can no longer detect loss of connectivity or switch network path

The two hardened cases show what each fix buys:

  • Relay-only removes the host and server-reflexive candidates, so the page cannot report the machine's own address. But a STUN query still left the machine, the relay allocation is still a unique key for the session, and a site that correlates relay addresses across visits still has an identifier.
  • Removing the API hides everything, and also removes a real feature. A page can no longer detect connectivity loss, cannot switch network path, and a WebRTC-based support widget or video feature breaks. A profile where RTCPeerConnection is undefined on a desktop Chrome is itself a signal.

The residual leak when only one of the two APIs is patched is the general form of this lesson: partial suppression produces a more distinctive profile, not a less detectable one, because the missing half is a signal in its own right.

The Network Information API

navigator.connection exposes the Network Information API, and it leaks the quality of the link rather than its address:

Property Meaning Typical values
effectiveType a coarse class of connection slow-2g, 2g, 3g, 4g
downlink estimated downlink in Mbit/s rounded, e.g. 10, 1.45
rtt estimated round-trip time in ms, rounded to 50 ms 50, 150, 450
saveData whether the user asked for reduced data true/false
type the interface type, Chromium only wifi, cellular, ethernet
onchange fires when the estimates change an event handler

The values are coarse and rounded, so they are not an address leak. They are a device-class leak: a desktop on Ethernet reports type: 'ethernet' and a high downlink, a phone on a train reports 3g and a low rtt. Combined with the claimed User-Agent and screen size, a mobile profile reporting wifi with downlink: 10 and a desktop profile reporting cellular are both contradictions.

effectiveType also changes the behaviour of a real browser: a page may load fewer resources or request a smaller variant. So the value is a statement about what the browser did, not only about the network, and a client that reports 2g while fetching everything at full size has another mismatch.

Some Chromium builds expose the carrier name in type or in a related field on mobile, which is a considerably stronger identifier than a link-quality estimate and is worth checking on the platform you are running.

What Automation Does to These Values

A headless Chromium launched with --disable-webrtc or a fake device set changes the picture:

  • --disable-webrtc removes the API surface, so RTCPeerConnection throws or is undefined. The page's WebRTC probe fails, which is detectable and also breaks real features.
  • Restricting ICE candidate types (Chromium's --force-webrtc-ip-handling-policy, or the enterprise policies behind it) can suppress host and srflx candidates and leave only relay.
  • Playwright's context.grant_permissions([]) and a fake device profile change the permissions answers but not the ICE behaviour, which is a common half-measure.
  • A fake device set with no real display, no sound card and a virtual time source changes NetworkInformation indirectly, because Chromium's estimates come from the network service and the OS, and a container has neither.

The result is a profile where WebRTC is absent and the connection reports values no real network produces. Both halves are covered by the pipeline described in Hardening Playwright with CDP, which is where the overrides have to be installed: before the page's first script runs, not after.

The Fix Patterns

For a browser profile you control:

  • Disable WebRTC in the profile where the target does not need it. Chromium enterprise policy, or the launch flag; accept that the page will notice.
  • Restrict ICE candidate types rather than removing the API, if the page must keep working: force relay-only, and understand that the STUN query still happens.
  • Override navigator.connection before page scripts run. addInitScript in Playwright, or Page.addScriptToEvaluateOnNewDocument over CDP, so the page never observes the real values. Overriding it afterwards is detectable for the same reason overriding navigator.language is: the page already read the real one.
  • Block the STUN and TURN hosts at the network layer as well, so the addresses are not merely unreported but never resolved.
  • Patch both APIs, or neither. The residual leak above is the reason.

A Checklist for These Two Leaks

  • Assume any proxied page can be asked for the real public IP. If a page can run a WebRTC probe, the proxy is not concealing the machine.
  • Suppress both APIs, in the profile, before the first script. A late override is a visible change.
  • Relay-only is not the same as off, and off is not the same as invisible: both states are fingerprints.
  • Do not forget candidate timing. The gaps between host, srflx and relay candidates are a behavioural signal.
  • Align NetworkInformation with the claimed device, and with what the page actually did with the value.
  • Remember that mDNS is a stable per-origin identifier, which is a different problem from the address leak it fixes.
  • Expect the site to correlate rather than to check. Both APIs produce values that repeat across visits, and repeatability is what makes them useful.

The Legitimate Route

Turning off WebRTC, or suppressing the NetworkInformation values, on a machine you own to stop a site learning your IP is ordinary privacy practice and requires no justification. Doing the same to a site you are scraping without authorisation, in order to stop it correlating your visits, is a different act: you are removing a signal from someone else's detection system, and that belongs behind written permission. The distinction is the same one drawn in Browser Proxies and DNS Leaks, where the same question arises for DNS, and in Legal and Compliance Boundaries.

Cross-links: Browser Proxies and DNS Leaks, Hardening Playwright with CDP, Fixing CDP & Headless Leaks, Device Profile Consistency, Storage Probes, ETags and Supercookies, Browser Pool Architecture, Legal and Compliance Boundaries.