My regex found an IP address in the word "candidate"
TL;DR: Versions 1.0.0 and 1.0.1 of my npm package @myipscan/leak-check reported strings like e:2703186080 as IP addresses. A hex character class matched the last letter of candidate: plus the ICE foundation, and my Node
TL;DR: Versions 1.0.0 and 1.0.1 of my npm package @myipscan/leak-check reported strings like e:2703186080 as IP addresses. A hex character class matched the last letter of candidate: plus the ICE foundation, and my Node test never reached the parser. 1.0.2 reads the address field (from candidate.address, or by position), validates it and skips mDNS names. If you pull IPs out of ICE candidate strings with a regex, check yours.
Two addresses that weren't
On 17 August 2026 my WebRTC leak detector told me my browser was exposing two IP addresses:
{ "supported": true, "ips": ["e:2703186080", "e:1510270939"] }
Neither is an address. By then the package had shipped twice, the second time with a passing test, and the e turned out to be the last letter of the word candidate.
The package is @myipscan/leak-check. I wrote it for MyIPScan, my set of free browser privacy checks. It asks the browser which addresses it will hand to a page script, and its only network traffic is a STUN request, to Google's server by default.
What bothered me most was how well the output survived a glance: a hex digit, a colon, a run of numbers. Squint and it could be the tail of an IPv6 address.
A detector that throws costs me a visitor. One that returns a confident wrong value costs the visitor. They compare it with their VPN's exit address and decide either that something leaked or that leak tests are noise, and either way they act on a fact my code made up. For a privacy tool the order is fixed: the right answer, then an honest "couldn't check", then anything else. 1.0.0 and 1.0.1 delivered the third.
How my test went green
My test ran the detector in Node. On 1.0.0 it failed with ReferenceError: window is not defined, because the code touched window inside the Promise.
1.0.1 went out 91 minutes later with a guard at the top of the Promise:
if (typeof window === "undefined") return resolve({ supported: false, ips: [] });
The test went green because that line is where it ended. I had made it pass by making it test less.
The same release moved the regex into a variable, var IP. I edited the line holding the bug, gave the bug a name and published it again.
The e in candidate
var IP = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9:]{10,})/i;
// a server-reflexive candidate; 203.0.113.7 is a documentation address
var line =
"candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx raddr 0.0.0.0 rport 0";
var m = IP.exec(line);
console.log(m[1], m.index); // "e:1510270939" 8
JavaScript returns the leftmost match, and at each position it tries both alternatives before moving one character right. The IPv4 branch only wins if nothing matched earlier in the string.
The second branch wants ten or more characters from [a-f0-9:], case-insensitive. Here is where it finds them:
candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx ...
ca d da e:1510270939
^ 8: first run of 10+ characters from [a-f0-9:]
^ 38: the address
The runs are ca, d, da, then e:1510270939, twelve characters. The match starts at 8 and the address is never considered.
The IPv6 branch described which characters an address may contain, not what an address looks like. A candidate line is full of long hex-looking runs.
On host and server-reflexive lines the old regex lands on the foundation or, when the foundation is short, on the ten-digit priority. Add a TURN server and relay candidates carry priorities short enough that it sometimes reaches the real address, which is how a bug like this survives a spot check. The foundation isn't even always a number: RFC 8839 allows letters in it too.
Read the field the grammar names
The candidate attribute is defined in RFC 8839, section 5.1: "candidate" ":" foundation SP component-id SP transport SP priority SP connection-address SP port SP cand-type .... The address is the fifth space-separated field. (My source comment still cites RFC 5245, which RFC 8839 replaced.)
The core of 1.0.2, condensed:
// candidate:<foundation> <component-id> <transport> <priority> <address> <port> typ <type> ...
function addressOf(c) {
if (c.address) return c.address; // newer browsers parse it for you
const fields = String(c.candidate || "").split(" ");
return fields.length > 4 ? fields[4] : ""; // fifth field, by position
}
function isIp(s) {
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
if (v4) return v4.slice(1).every((n) => Number(n) <= 255);
if (!s.includes(":")) return false;
const halves = s.split("::"); // at most one "::"
const groups = s.split(":").filter(Boolean);
if (halves.length > 2 || groups.length > 8) return false;
if (halves.length === 1 && groups.length !== 8) return false;
return groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g));
}
pc.onicecandidate = (e) => {
if (!e.candidate) return finish(); // null candidate: gathering is over
const addr = addressOf(e.candidate);
if (/\.local$/i.test(addr)) return; // mDNS name: the browser withheld the IP
if (isIp(addr)) ips.add(addr);
};
The test I should have written feeds the detector the two candidates a typical browser produces, an mDNS host candidate and a server-reflexive one:
const test = require("node:test");
const assert = require("node:assert");
const { detectWebRTCLeak } = require("@myipscan/leak-check");
function fakePC(lines) {
return function () {
this.createDataChannel = () => {};
this.close = () => {};
this.createOffer = async () => ({});
this.setLocalDescription = async () => {
setTimeout(() => {
lines.forEach((candidate) => this.onicecandidate({ candidate: { candidate } }));
this.onicecandidate({ candidate: null });
});
};
};
}
test("returns the address from a server-reflexive candidate", async (t) => {
t.after(() => { delete global.window; });
global.window = { RTCPeerConnection: fakePC([
"candidate:2703186080 1 udp 2122260223 9b36a1c4-5e2f-4d1a-8c3b-0f7e6d5c4b3a.local 54321 typ host",
"candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx raddr 0.0.0.0 rport 0",
]) };
const { ips } = await detectWebRTCLeak();
assert.deepStrictEqual(ips, ["203.0.113.7"]);
});
On 1.0.1 it returns ["e:2703186080", "e:1510270939"], exactly the output I saw in the browser. On 1.0.2 it returns ["203.0.113.7"] and passes.
RTCIceCandidate.address saves the split where it exists, but MDN lists it as Baseline only since 2024, so the fallback stays. isIp checks shape only: it lets a trailing colon through and rejects the valid ::ffff:192.0.2.1. If you need strict IPv6 in a browser, new URL("http://[" + s + "]") throws on malformed input. What the new code can no longer do is return a piece of another field.
The broken versions were the latest release for about two hours, 17:56 to 20:04 UTC on 17 August. Both are still on the registry, so use ^1.0.2. 1.0.3 only added repository and issue links. The source is on GitHub.
Three results, not two
-
supported: false: the check could not run (nowindow, noRTCPeerConnection, or the constructor threw). It never means "no leak". -
supported: truewith addresses: what the browser was willing to give a page script. A public address here is expected. It is a leak only if it differs from the address your HTTP requests come from, for example your ISP's address while a VPN is on. -
supported: true, ips: []: no address came back. With a reachable STUN server you would normally see at least your public address, so an empty list usually means STUN was blocked or slower than the 2-second timeout. In 1.0.2 it is also what a failed offer returns. On its own it proves nothing. (Private addresses hidden behind mDNS names ending in.localare skipped in every case.)
The trap downstream is if (!ips.length) showClean(). That turns "I couldn't look" into "nothing there".
Rules I'm keeping
-
Test the path that touches the browser. A fake
RTCPeerConnectionthat replays recorded candidate lines is a few lines of code, and it fails on 1.0.1. - Parse by the grammar, then validate. If the spec says field five, read field five, then check its shape.
- Never let "could not check" render as "clean". Carry the third state all the way to the UI.
-
Read every line before you publish it. 1.0.0's
index.jswas 19 lines, and I published it without reading all of them.npm pack --dry-runshows what will ship. Reading it is still on me.
Rule 3 is the one I haven't finished. 1.0.2 still folds a STUN timeout or a failed offer into supported: true with an empty list, which is exactly the collapse rule 3 warns about. The fix is an explicit "incomplete" state, and it isn't in the package yet. If you've shipped a check with a third state, how did you get it to the UI without someone turning it back into a boolean?
If you want to see what your own browser hands out, MyIPScan's WebRTC leak test runs the same gathering in the page and sorts candidates into public, private and mDNS-masked. (I build MyIPScan. The package is MIT licensed.)
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.