IPv6アドレスをRFC 5952で正規化 — ::の位置とIPv4射影

2001:db8:0:0:1:0:0:1 と 2001:DB8::1:0:0:1 は、どちらも同じ IPv6 アドレスである。ところが文字列としては一致しないので、アクセスログを grep したり、許可リストと突き合わせたり、設定ファイルの差分を取ったりすると、同じアドレスが別物として扱われる。

IPv6 アドレスの書き方を1つに絞るための規則が RFC 5952(A Recommendation for IPv6 Address Text Representation、2010年8月、RFC 4291 を更新)である。この記事では、ハシトシステムのIPv6アドレス展開・短縮ツールが実装している短縮処理を読みながら、

  • RFC 5952 が決めている規則と、それが必要な理由
  • :: をどこに置くかの判定を、実装でどう書くか
  • IPv4 射影アドレス(::ffff:192.0.2.128)の表記が、実装ごとに分かれること

を、手元で動かしたコードで確かめる。確認した環境は Windows 11 / Node.js v24.11.1 / Python 3.11.9・3.12.10 である。

1つのアドレスに8通りの書き方がある

IPv6 アドレスは 16 ビットずつ8グループに区切り、16進で書く。RFC 4291 の section 2.2 は、各グループの先頭の0を省いてよいこと、0が続くグループを :: で1回だけ省略してよいこと、末尾32ビットを 192.0.2.1 のような10進で書いてよいことを定めている。どれも「書いてよい」なので、同じアドレスに複数の書き方が成立する。

RFC 5952 の section 2 は、その例として次の8通りを挙げている。すべて同じアドレスである。

1
2
3
4
5
6
7
8
2001:db8:0:0:1:0:0:1
2001:0db8:0:0:1:0:0:1
2001:db8::1:0:0:1
2001:db8::0:1:0:0:1
2001:0db8::1:0:0:1
2001:db8:0:0:1::1
2001:db8:0000:0:1::1
2001:DB8:0:0:1::1

:: を前の 0:0 に使うか後ろの 0:0 に使うか、先頭の0を残すか、大文字で書くか。この自由度があるので、機械が出力した値と人が書いた値が一致しない。

RFC 5952 の規則

RFC 5952 の section 4 は、出力する側が守るべき規則を次のように定めている(いずれも MUST)。

節 規則 例
4.1 各グループの先頭の0は省く 2001:0db8::0001 ではなく 2001:db8::1
4.2.1 :: は省略できる限り使う 2001:db8:0:0:0:0:2:1 ではなく 2001:db8::2:1
4.2.2 0が1グループだけのところに :: を使わない 2001:db8:0:1:1:1:1:1 が正しく、2001:db8::1:1:1:1:1 は誤り
4.2.3 候補が複数あれば最も長い0の連続を省く。長さが同じなら左側を省く 冒頭の例は 2001:db8::1:0:0:1 になる
4.3 16進の英字は小文字で書く 2001:DB8::1 ではなく 2001:db8::1

4.2.2 は見落としやすい。:: は「0のグループが1つ以上」を表せるので、1グループだけの0に使っても読み込み側は正しく復元できる。それでも RFC 5952 は、書き方を1つに絞るためにこれを禁じている。

実装:最長の0連続を探す

ハシトシステムのIPv6アドレス展開・短縮ツールは、外部ライブラリを使わずにページ内の JavaScript で変換している。入力を8グループの数値配列にしたうえで、短縮は次の関数で行う(ツールのソースをそのまま引用)。

1
2
3
4
5
6
7
8
9
10
11
12
/* RFC 5952: 最長の0連続を1回だけ :: にする。長さ1は圧縮しない。同点は左優先。 */
function short(g){
var best=-1,bestLen=0,cur=-1,len=0,i;
for(i=0;i<8;i++){
if(g[i]===0){ if(cur<0){cur=i;len=1;} else len++; if(len>bestLen){bestLen=len;best=cur;} }
else{cur=-1;len=0;}
}
if(bestLen<2)return noPad(g);
var head=g.slice(0,best).map(function(n){return n.toString(16);}).join(':');
var tail=g.slice(best+bestLen).map(function(n){return n.toString(16);}).join(':');
return head+'::'+tail;
}

RFC 5952 の規則は、この関数の3か所に対応している。

  • 最長を選ぶ(4.2.3):len>bestLen のときだけ最長を更新する
  • 同点は左(4.2.3):比較が > で >= ではないので、同じ長さの連続が後から来ても更新されず、先に見つけた左側が残る
  • 1グループだけは省かない(4.2.2):bestLen<2 なら :: を使わず、noPad(先頭の0を省いてコロンでつなぐだけ)を返す

先頭の0を省く(4.1)と小文字(4.3)は、n.toString(16) が両方を満たす。数値に直してから16進に戻すので、0DB8 は db8 になる。

ブラウザの URL パーサと同じ結果になるか

同じ規則は、WHATWG の URL Standard にも入っている。URL のホスト部分に [2001:DB8::1] のような IPv6 アドレスを書くと、URL パーサは IPv6 シリアライザでそれを書き直す。仕様の IPv6 シリアライザには、この処理が RFC 5952 の推奨に従うための手順である旨が注記されている。圧縮する位置を求める手順は、0の連続のうち最初に見つかった最長のものを選び、長さ1の連続は圧縮しない(longestSize の初期値が1で、それより長い場合だけ採用する)。

Node.js の URL も WHATWG URL の実装なので、ツールの関数と結果を突き合わせられる。ツールの HTML からこの関数を取り出して、new URL() の結果と比べたところ、次の入力はすべて同じ文字列になった。

入力 ツールの short() new URL() のホスト
2001:0DB8:0000:0000:0001:0000:0000:0001 2001:db8::1:0:0:1 2001:db8::1:0:0:1
2001:db8:0:1:1:1:1:1 2001:db8:0:1:1:1:1:1 2001:db8:0:1:1:1:1:1
2001:db8:0:0:1:0:0:0 2001:db8:0:0:1:: 2001:db8:0:0:1::
0:f:0:0:f:f:0:0 0:f::f:f:0:0 0:f::f:f:0:0
1:0:0:2:0:0:0:3 1:0:0:2::3 1:0:0:2::3
1:2:3:4:5:6:7:: 1:2:3:4:5:6:7:0 1:2:3:4:5:6:7:0

0:f:0:0:f:f:0:0 は、長さ2の0連続が2か所(3〜4番目と7〜8番目)にある。左側が選ばれて 0:f::f:f:0:0 になる。URL Standard 自身も、この入力で2番目の0(左側の連続の先頭)を指す例を載せている。

最後の 1:2:3:4:5:6:7:: は、入力としては正しい(:: が1グループの0を表す)。しかし正規化すると、4.2.2 により :: を使わない 1:2:3:4:5:6:7:0 に戻る。

IPv4 射影アドレスは実装で表記が分かれる

RFC 5952 の section 5 は、IPv4 アドレスを埋め込んだアドレスについて、よく知られたプレフィックスから埋め込みだと判別できる場合は、下位32ビットを10進で書く混在表記を推奨(RECOMMENDED)している。IPv4 射影アドレス(::ffff:0:0/96)なら ::ffff:192.0.2.1 の形である。section 4 の規則と違い、ここは MUST ではない。

実際に同じ ::ffff:192.0.2.128 を渡すと、結果は次のように分かれた。

実装 出力
Node.js v24.11.1 の new URL() ::ffff:c000:280
ハシトシステムのツール(推奨表記の欄) ::ffff:c000:280
Python 3.11.9 / 3.12.10 の ipaddress.IPv6Address を str() ::ffff:c000:280
Python 3.11.9 の socket.inet_ntop(Windows 11) ::ffff:192.0.2.128

URL Standard の IPv6 シリアライザは、各グループを「最短の小文字16進」で出力する手順しか持たないので、IPv4 射影でも16進になる。ツールは推奨表記の欄を16進で出したうえで、別の欄に「埋め込まれたIPv4」として 192.0.2.128 を表示している。Python の ipaddress では、ipv4_mapped 属性で埋め込まれた IPv4 アドレスを取り出せる。

これが実害になるのは、Node.js のサーバが受け取ったアドレスである。:: で待ち受ける(IPv4 と IPv6 の両方を受ける)サーバに 127.0.0.1 から接続すると、socket.remoteAddress は ::ffff:127.0.0.1 と混在表記で返ってきた。これを new URL() で正規化すると ::ffff:7f00:1 になる。つまり、「受け取ったアドレス」と「正規化した許可リスト」を文字列で比べると、同じ接続元でも一致しない。IPv4 射影アドレスを扱うときは、どちらの表記に揃えるかを決めて、比べる両側を同じ関数に通す必要がある。

ゾーンIDは URL では書けない

fe80::1%en0 の %en0 は、リンクローカルアドレスがどのインターフェースのものかを示すゾーンIDである。Python の ipaddress.IPv6Address('fe80::1%en0') は受け付け、scope_id 属性で取り出せる(Python 3.5 で追加)。Node.js の net.isIPv6('fe80::1%en0') も true を返した。

一方、URL Standard は IPv6 のホスト構文について、ゾーンIDのサポートを意図的に省いていると明記している。Node.js v24.11.1 の new URL('http://[fe80::1%en0]/') は ERR_INVALID_URL で失敗した。ツールはゾーンIDを切り離してアドレス部分だけを変換し、表示のときに付け直している。自前で正規化するときも、% 以降を先に分けておくのが安全である。

実際に試す

前提:Node.js(筆者は v24.11.1 で確認。ES モジュールの .mjs と、グローバルの URL を使う)。外部パッケージは使わない。

1. 正規化の関数を書く

構文の検査は WHATWG URL のパーサに任せ、戻ってきた文字列を8グループに展開してから、RFC 5952 の規則で書き直す。IPv4 射影アドレスを混在表記で出す関数も用意する。

rfc5952.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// IPv6 文字列 -> 8グループの数値配列(ゾーンIDは分けて返す)。不正なら null
export function parse(input) {
const [addr, zone = null] = input.split('%');
let host;
try {
// 構文の検査と IPv4 部分の変換は WHATWG URL のパーサに任せる
host = new URL(`http://[${addr}]/`).hostname.slice(1, -1);
} catch {
return null;
}
const [head, tail] = host.includes('::') ? host.split('::') : [host, null];
const h = head ? head.split(':') : [];
const t = tail ? tail.split(':') : [];
const fill = tail === null ? [] : Array(8 - h.length - t.length).fill('0');
return { groups: [...h, ...fill, ...t].map((x) => parseInt(x, 16)), zone };
}

// RFC 5952 section 4 の規則で短縮する(最長の0連続・長さ2以上・同点は左・小文字)
export function format(groups) {
let best = -1, bestLen = 1;
for (let i = 0; i < 8; ) {
if (groups[i] !== 0) { i++; continue; }
let j = i;
while (j < 8 && groups[j] === 0) j++;
if (j - i > bestLen) { best = i; bestLen = j - i; }
i = j;
}
const hex = (a) => a.map((n) => n.toString(16)).join(':');
if (best < 0) return hex(groups);
return hex(groups.slice(0, best)) + '::' + hex(groups.slice(best + bestLen));
}

// section 5: IPv4 射影(::ffff:0:0/96)は下位32ビットを10進で書く
export function formatMixed(groups) {
const mapped = groups.slice(0, 5).every((n) => n === 0) && groups[5] === 0xffff;
if (!mapped) return format(groups);
const [hi, lo] = [groups[6], groups[7]];
return `::ffff:${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
}

bestLen の初期値を1にして > で比べているので、長さ1の0は選ばれず(4.2.2)、同じ長さなら左が残る(4.2.3)。URL Standard の手順と同じ考え方である。混在表記は、この記事では IPv4 射影アドレスだけを対象にしている。RFC 5952 の section 5 は ISATAP などほかの埋め込み形式も挙げているので、必要なら条件を足す。

2. RFC の8通りが1つにまとまるか確かめる

check.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { parse, format, formatMixed } from './rfc5952.mjs';

// RFC 5952 section 2 に並ぶ「同じアドレスの別表記」
const same = [
'2001:db8:0:0:1:0:0:1',
'2001:0db8:0:0:1:0:0:1',
'2001:db8::1:0:0:1',
'2001:db8::0:1:0:0:1',
'2001:0db8::1:0:0:1',
'2001:db8:0:0:1::1',
'2001:db8:0000:0:1::1',
'2001:DB8:0:0:1::1',
];
console.log('文字列として異なる数:', new Set(same).size);
console.log('正規化後に異なる数 :', new Set(same.map((s) => format(parse(s).groups))).size, '->', format(parse(same[0]).groups));

for (const s of ['2001:db8:0:1:1:1:1:1', '0:f:0:0:f:f:0:0', '::ffff:192.0.2.128', 'fe80::1%en0', '1::2::3']) {
const r = parse(s);
if (!r) { console.log(s.padEnd(22), '-> 不正'); continue; }
const z = r.zone ? '%' + r.zone : '';
console.log(s.padEnd(22), '->', format(r.groups) + z, '| mixed:', formatMixed(r.groups) + z);
}
1
node check.mjs

期待される出力:

1
2
3
4
5
6
7
文字列として異なる数: 8
正規化後に異なる数 : 1 -> 2001:db8::1:0:0:1
2001:db8:0:1:1:1:1:1 -> 2001:db8:0:1:1:1:1:1 | mixed: 2001:db8:0:1:1:1:1:1
0:f:0:0:f:f:0:0 -> 0:f::f:f:0:0 | mixed: 0:f::f:f:0:0
::ffff:192.0.2.128 -> ::ffff:c000:280 | mixed: ::ffff:192.0.2.128
fe80::1%en0 -> fe80::1%en0 | mixed: fe80::1%en0
1::2::3 -> 不正

8通りの表記が、正規化後は 2001:db8::1:0:0:1 の1つにまとまる。:: を2回使った 1::2::3 は、どこに何グループ入るかが決まらないので URL パーサが拒否し、null になる。

3. サーバが受け取るアドレスの表記を見る

remote.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import net from 'node:net';

// '::' で待ち受けるとデュアルスタックになり、IPv4 の接続は IPv4 射影アドレスで見える
const server = net.createServer((sock) => {
const ra = sock.remoteAddress;
console.log('remoteAddress :', ra, '| family:', sock.remoteFamily);
console.log('URL で正規化 :', new URL(`http://[${ra}]/`).hostname.slice(1, -1));
sock.end();
server.close();
});
server.listen(0, '::', () => {
const { port } = server.address();
net.connect(port, '127.0.0.1');
});
1
node remote.mjs

筆者の環境(Windows 11 / Node.js v24.11.1)での出力:

1
2
remoteAddress   : ::ffff:127.0.0.1 | family: IPv6
URL で正規化 : ::ffff:7f00:1

同じ接続元が、受け取った時点では混在表記、URL パーサを通すと16進表記になる。許可リストやレート制限のキーにアドレスを使うなら、両側を formatMixed(parse(x).groups) のように同じ関数へ通してから比べる。OS の設定によっては :: で待ち受けても IPv4 を受けない場合があるので、そのときは接続が失敗する。

4. Python での確認

1
python -c "import ipaddress as i; a=i.IPv6Address('2001:DB8:0:0:1::1'); print(a, a.exploded); m=i.IPv6Address('::ffff:192.0.2.128'); print(m, m.ipv4_mapped)"

筆者の環境(Python 3.11.9 と 3.12.10)での出力:

1
2
2001:db8::1:0:0:1 2001:0db8:0000:0000:0001:0000:0000:0001
::ffff:c000:280 192.0.2.128

str() は RFC 5952 の section 4 どおりの短縮形、exploded は4桁ゼロ埋めの完全形になる。IPv4 射影は、このバージョンでは16進で表示され、埋め込まれた IPv4 は ipv4_mapped で取り出せた。表記は実装やバージョンで変わりうるので、突き合わせに使う前に手元で1回出力を見ておくとよい。

ブラウザだけで確かめたい場合は、IPv6アドレス展開・短縮ツールに 2001:DB8:0:0:1::1 や ::ffff:192.0.2.128、fe80::1%en0 を入れると、推奨表記・完全形・ゼロ埋めなしの形と、埋め込まれた IPv4 やゾーンIDが並んで表示される。末尾に /64 のようなプレフィックス長を付ければ、ネットワークアドレスと範囲も出る。変換はブラウザ内で完結し、入力は送信されない。

文字列を比べる前に正規化が要る、という点はドメイン名でも同じである。日本語ドメインの xn-- 表記で大文字や全角が結果を変える話は、日本語ドメインのxn–をJSで作る記事で扱っている。

まとめ

  • IPv6 アドレスは RFC 4291 の省略規則だけだと何通りにも書ける。RFC 5952 の section 4 は、先頭の0を省く、:: は最長の0連続に1回だけ(同点は左、長さ1には使わない)、英字は小文字、で1つに絞る
  • ハシトシステムのツールの短縮関数と Node.js の new URL() は、試した入力ですべて同じ結果になった。WHATWG URL の IPv6 シリアライザも同じ規則で :: の位置を決める
  • IPv4 射影アドレスの10進表記は RFC 5952 でも推奨(RECOMMENDED)にとどまり、Node.js の URL パーサと Python の ipaddress は16進、Node.js の remoteAddress や Windows の inet_ntop は10進で返した。比べる両側を同じ関数に通す
  • ゾーンID(%en0)は URL の構文では書けない。正規化の前に切り離しておく

参照した一次情報