detectWebRTCLeak() が返す ips の配列に、IP アドレスではなく e:2703186080 が入っていました。返ってきた値はどれも、candidate 行の中でアドレスより左にある断片でした。
私は MyIPScan を作っていて、WebRTC リークを調べる小さな npm パッケージ @myipscan/leak-check も公開しています。上の文字列は、そのパッケージの 1.0.0 と 1.0.1 が返していた「IP アドレス」です。
この記事では、ICE candidate から IP アドレスを取り出すときに正規表現で文字列全体を探すのをやめ、RFC 8839 の文法どおりにフィールドの位置で読む方法を、コードとテスト付きで説明します。
何が起きていたか
1.0.1 の実装はこうでした(1.0.0 も同じ正規表現です)。
var IP = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9:]{10,})/i;
pc.onicecandidate = function (e) {
if (!e.candidate) return finish();
var m = IP.exec(e.candidate.candidate);
if (m) ips.add(m[1]);
};
前半が IPv4、後半が IPv6 のつもりです。ところが後半の [a-f0-9:]{10,} は「16進数字とコロンが10文字以上並んだもの」でしかありません。
candidate 文字列は次のような形をしています。
candidate:2703186080 1 udp 2122260223 192.0.2.10 54400 typ host generation 0
正規表現エンジンは左から順に一致を探し、最初に見つかったものを返します。candidate の c、a、d、e はどれも16進数字です。ca は n で、da は t で途切れて10文字に届きません。最後の e からは、e、:、そして10桁の foundation と、すべてが文字クラスに収まります。合計12文字なので {10,} を満たします。
その結果、5番目のフィールドにある本物のアドレスに届く前に、e:2703186080 で一致が確定していました。
位置がずれると壊れ方も変わる
Node.js で同じ正規表現にいくつかの candidate を通すと、壊れ方はひとつではありません。
| candidate の先頭部分 | 古い正規表現の結果 |
|---|---|
candidate:2703186080 1 udp 2122260223 … |
e:2703186080 |
candidate:842163049 1 udp 1677729535 … |
e:842163049 |
candidate:1 1 udp 2122194687 … |
2122194687 |
candidate:3 1 UDP 2122187007 8f3c1a2b-….local … |
2122187007 |
foundation が短いと e:1 や e:3 は10文字に届かず、今度は priority の10桁が「IPv6」として拾われます。最後の行はアドレス欄が mDNS のホスト名で、そもそも IP アドレスがありません。それでも古い正規表現は priority を返します。どの行でも、アドレスより左にあるフィールドが先に一致します。
正規表現で直そうとすると、結局「何番目の空白の後か」をパターンに埋め込むことになります。それなら最初から位置で読むほうが素直です。
RFC 8839 5.1 が決めている並び
candidate 行の文法は RFC 8839 の 5.1 節で定義されています(ICE の手順そのものは RFC 8445 です)。
5.1 節の ABNF を抜き出すとこうなります。
candidate-attribute = "candidate" ":" foundation SP component-id SP
transport SP
priority SP
connection-address SP
port
SP cand-type
[SP rel-addr]
[SP rel-port]
*(SP cand-extension)
cand-type = "typ" SP candidate-types
rel-addr = "raddr" SP connection-address
rel-port = "rport" SP port
candidate: を外して空白で区切れば、先頭から foundation、component、transport、priority、connection-address、port、typ、タイプの順です。アドレスは5番目のフィールド(0始まりで4)に固定されています。その後ろには raddr 0.0.0.0 や generation 0 のような「名前 値」のペアが続きます。
同じ節には、connection-address には IPv4、IPv6 のほか FQDN も書けること、そして FQDN を含む candidate 行は無視しなければならないことも書かれています。mDNS の .local は、まさにこの FQDN の例です。つまり5番目のフィールドを取っても、それが IP アドレスとは限りません。取り出したあとの検証が必要です。
パーサを書く
位置で読むパーサと、構造で IP かどうかを判定する関数です。isIp は 1.0.2 で入れたものとほぼ同じです。パッケージの addressOf は split(" ")[4] で5番目を取るだけですが、ここでは typ の位置も確かめる、少し厳しい版にしています。
// candidate 行を RFC 8839 5.1 の並びで分解する
function parseCandidate(line) {
const s = String(line || "").trim().replace(/^a=/, "");
if (!s.startsWith("candidate:")) return null;
const f = s.slice("candidate:".length).split(/\s+/);
// foundation component transport priority address port "typ" type
if (f.length < 8 || f[6] !== "typ") return null;
const c = {
foundation: f[0],
component: Number(f[1]),
transport: f[2].toLowerCase(),
priority: Number(f[3]),
address: f[4],
port: Number(f[5]),
type: f[7],
};
// 残りは「名前 値」のペア (raddr, rport, generation など)
for (let i = 8; i + 1 < f.length; i += 2) {
if (f[i] === "raddr") c.relatedAddress = f[i + 1];
if (f[i] === "rport") c.relatedPort = Number(f[i + 1]);
}
return c;
}
// 構造で判定する。文字クラスだけでは判定しない
function isIp(s) {
if (!s) return false;
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 runs = s.split("::");
if (runs.length > 2) return false;
const groups = s.split(":").filter((g) => g !== "");
if (groups.length > 8 || (runs.length === 1 && groups.length !== 8)) return false;
return groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g));
}
// RTCIceCandidate でも文字列でも受け取る
function addressOf(candidate) {
if (candidate && typeof candidate === "object") {
if (candidate.address) return candidate.address;
candidate = candidate.candidate;
}
const c = parseCandidate(candidate);
return c ? c.address : "";
}
function classify(addr) {
if (/\.local$/i.test(addr)) return "mdns";
if (isIp(addr)) return "ip";
return "unknown";
}
module.exports = { parseCandidate, isIp, addressOf, classify };
ポイントは3つです。
-
RTCIceCandidate.addressがあればそれを使います。MDN によると、この値は candidate の a 行から自動で取り出された5番目のフィールドで、既定値はnullです。ただし MDN の互換性表では、Firefox が対応したのは 126 からです(Chrome は 74、Safari は 14.1 から)。古いブラウザではaddressがないので、文字列を読む経路を残しています。 -
addressがなければ文字列を空白で区切り、f[4]を読みます。f[6] !== "typ"の行は形が想定と違うので、推測せずにnullを返します。 - 取り出した値は
isIpで確かめます。.localで終わるものは mDNS ホスト名です。ブラウザがプライベートアドレスを伏せた結果なので、IP として報告しません。報告しないことが正しい結果です。
なお isIp は簡易版です。::ffff:192.0.2.1 のような IPv4 埋め込み形式や、2001:db8::1%en0 のようなゾーン ID 付きの表記は false になります。これらを扱う場合は判定を足してください。
テストで古い挙動ごと固定する
node:test で、古い正規表現の壊れ方と新しいパーサの結果を両方書いておきます。アドレスはすべて文書用の範囲(192.0.2.0/24、198.51.100.0/24、203.0.113.0/24、2001:db8::/32)です。
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseCandidate, addressOf, classify } = require("./candidate.js");
const OLD = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9:]{10,})/i;
const cases = [
["candidate:2703186080 1 udp 2122260223 192.0.2.10 54400 typ host generation 0", "192.0.2.10", "ip"],
["candidate:842163049 1 udp 1677729535 203.0.113.7 54401 typ srflx raddr 0.0.0.0 rport 0 generation 0", "203.0.113.7", "ip"],
["candidate:1 1 udp 2122194687 2001:db8::1 54402 typ host", "2001:db8::1", "ip"],
["candidate:3 1 UDP 2122187007 8f3c1a2b-0000-4c5d-9e8f-123456789abc.local 54403 typ host",
"8f3c1a2b-0000-4c5d-9e8f-123456789abc.local", "mdns"],
];
test("古い正規表現はアドレスではなく断片を拾う", () => {
assert.equal(OLD.exec(cases[0][0])[1], "e:2703186080");
assert.equal(OLD.exec(cases[2][0])[1], "2122194687");
});
for (const [line, addr, kind] of cases) {
test(`5番目のフィールドを読む: ${addr}`, () => {
assert.equal(addressOf(line), addr);
assert.equal(classify(addr), kind);
});
}
test("srflx の raddr/rport も取れる", () => {
const c = parseCandidate(cases[1][0]);
assert.equal(c.type, "srflx");
assert.equal(c.relatedAddress, "0.0.0.0");
assert.equal(c.relatedPort, 0);
});
test("address プロパティがあればそちらを優先", () => {
assert.equal(addressOf({ address: "198.51.100.4", candidate: cases[0][0] }), "198.51.100.4");
});
test("end-of-candidates の空文字は空を返す", () => {
assert.equal(addressOf({ candidate: "" }), "");
assert.equal(classify(""), "unknown");
});
$ node --test
# tests 8
# pass 8
# fail 0
最初のテストは、古い正規表現が間違うことを確かめています。失敗の再現をテストに残しておけば、あとで誰かが「正規表現のほうが短い」と書き戻したときに、何が起きるかがその場で分かります。
最後のテストは MDN の記述に合わせたものです。candidate が空文字列なら、それは候補の列の終わりを示す end-of-candidates マーカーで、アドレスではありません。
パッケージ側の対応
npm の記録では、1.0.0 と 1.0.1 は2026年8月17日の公開で、同じ日に 1.0.2 を出しました。1.0.0 と 1.0.1 には「Returns ICE foundation fragments instead of IPs. Use >=1.0.2.」という非推奨メッセージを付けています。
npm install @myipscan/leak-check@^1.0.2
この記事の時点(2026年10月)の最新は 1.0.4 です。1.0.4 では、タイムアウトや offer の失敗を「何も漏れていない」と区別できるように status フィールドを足しました。空の ips が「ブラウザが何も明かさなかった」という意味になるのは、status が "complete" のときだけです。
ブラウザが実際にどのアドレスを明かすかは、MyIPScan の WebRTC リークテスト で、公開アドレス、ローカルアドレス、mDNS で伏せられたものに分けて確認できます。
覚えておくルール
決まった並びを持つ行から値を取るときは、文字の種類で探さず、何番目のフィールドかで読む。読んだあとで、その値が本当にその型かを確かめる。
正規表現は検証に使い、探索には使わない。e:2703186080 は、正規表現を探索に使った結果でした。