0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ICE candidate の IP アドレスは正規表現ではなく位置で読む

0
Posted at

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つです。

  1. RTCIceCandidate.address があればそれを使います。MDN によると、この値は candidate の a 行から自動で取り出された5番目のフィールドで、既定値は null です。ただし MDN の互換性表では、Firefox が対応したのは 126 からです(Chrome は 74、Safari は 14.1 から)。古いブラウザでは address がないので、文字列を読む経路を残しています。
  2. address がなければ文字列を空白で区切り、f[4] を読みます。f[6] !== "typ" の行は形が想定と違うので、推測せずに null を返します。
  3. 取り出した値は 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 は、正規表現を探索に使った結果でした。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?