はじめに
次のコードを見ると、hostname にはそのまま 2130706433 が入りそうに見えます。
new URL('http://2130706433').hostname
しかし、実際に返る値は 127.0.0.1 です。
127.0.0.1
2130706433 は、IPv4 アドレス 127.0.0.1 を 32 ビット整数として表した値です。
もう少し試してみます。
new URL('http://0x7f000001').hostname
// '127.0.0.1'
new URL('http://0177.0.0.1').hostname
// '127.0.0.1'
new URL('http://127.1').hostname
// '127.0.0.1'
見た目はかなり違いますが、WHATWG URL Standard に沿って解析すると同じホスト名になります。
WHATWG URL は、ブラウザーや Node.js の new URL() が従っている URL の標準仕様です。URL 文字列をどう解析し、ホスト名、パス、検索文字列などをどう取り出すかを定めています。
new URL() は「文字列をそのまま区切り文字で分割する関数」ではありません。入力を URL として解釈し、必要に応じて正規化したうえで、hostname や pathname などの値を返します。今回の 2130706433 が 127.0.0.1 になる挙動も、その URL 解析の一部です。
そのため、URL を受け取る機能では、入力文字列を直接見るだけでは不十分なことがあります。実際に通信へ使う前に、同じ URL パーサーを通した後の値を見る必要があります。
この挙動は、ユーザーから URL を受け取り、サーバー側からアクセスする機能で重要です。サーバーサイドリクエストフォージェリ(SSRF)対策として 127.0.0.1 を拒否していても、入力文字列だけを見ていると別表記を見逃すためです。
この記事では、IPv4 の変則表記を題材に、文字列を検証する前に、実際に使う解析器で正規化するという設計を整理します。
なぜ複数の表記があるのか
一般に IPv4 アドレスというと、次のようなドット区切りの 10 進表記を思い浮かべます。
127.0.0.1
192.168.0.1
10.0.0.1
しかし、URL の歴史は長く、Web には古い形式の入力も残っています。
WHATWG URL Standard には、IPv4 を解析するための手順が定義されています。
その中で、IPv4 は最大 4 つの要素に分けて解析されます。また、各要素は接頭辞に応じて、10 進数、16 進数、8 進数として読み取られます。数値部分の読み取りは、仕様上は IPv4 number parser に定義されています。
そのため、次の表記を解釈できます。
| 入力 | 表記の考え方 | 正規化後 |
|---|---|---|
127.0.0.1 |
通常のドット区切り | 127.0.0.1 |
2130706433 |
32 ビット整数 | 127.0.0.1 |
0x7f000001 |
16 進数 | 127.0.0.1 |
0177.0.0.1 |
8 進数を含む表記 | 127.0.0.1 |
127.1 |
要素を省略した表記 | 127.0.0.1 |
仕様上、これらの一部は検証エラーとして扱われます。それでも、URL としての解析結果は返ります。
これは Node.js 固有の挙動ではありません。Node.js の URL は、ブラウザーでも使われる WHATWG URL Standard に沿った API です。
Web 互換性を保つための寛容さが、セキュリティ境界では注意点になります。
入力文字列だけを検証すると見逃す
サーバーサイドリクエストフォージェリ対策では、ユーザーが指定した URL から内部 IP アドレスへアクセスされないようにします。
たとえば、次の URL は拒否したいはずです。
http://127.0.0.1/admin
http://169.254.169.254/latest/meta-data/
http://192.168.0.1/
ここで、入力文字列に次の正規表現を当てるだけでは不十分です。
function looksLikeIPv4(input: string): boolean {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(input);
}
looksLikeIPv4('127.0.0.1')
// true
looksLikeIPv4('2130706433')
// false
looksLikeIPv4('0x7f000001')
// false
この判定は、一般的な IPv4 表記しか見ていません。同じ IP アドレスを意味する別表記は通過します。
問題は正規表現の書き方だけではありません。アプリ側の検証と、後段の URL パーサーが、同じ文字列を異なる意味で捉えていることが問題です。
正規化してからの検証
対策の中心は単純です。
入力文字列を直接判定するのではなく、アプリが実際に使う URL パーサーへ先に通します。その後、正規化された hostname を検証します。
const url = new URL('http://2130706433');
console.log(url.hostname);
// '127.0.0.1'
順序にすると、次の 3 段階です。
1. new URL() で URL として解析する
2. url.hostname を取り出す
3. 正規化後の hostname を IP アドレス判定へ渡す
この考え方は IPv4 に限りません。
文字列を比較する前に、後段と同じ意味へそろえる。セキュリティ境界では、「正規化してから検証する」という順序が重要です。英語では canonicalize, then validate と呼ばれることがあります。
IP アドレスの判定には既存 API やライブラリを使う
正規化後の値が IP アドレスかどうかを調べるだけなら、Node.js 標準の net.isIP() を使えます。
import { isIP } from 'node:net';
const url = new URL('http://2130706433');
isIP(url.hostname)
// 4
4 は IPv4、6 は IPv6、0 は IP アドレスではないことを表します。
内部ネットワークや予約済み範囲、CIDR まで扱う場合は、ipaddr.js のようなライブラリが選択肢になります。ipaddr.js は JavaScript の標準 API ではありませんが、IPv4 と IPv6 の解析、CIDR との照合、予約済み範囲の判定を提供する小さなライブラリです。
次は、通常のユニキャストとして扱わない範囲を拒否する考え方を示す最小例です。ここでは、説明を簡単にするため isBlockedIp() という名前にしています。
import ipaddr from 'ipaddr.js';
function normalizeIpLiteral(hostname: string): string {
if (hostname.startsWith('[') && hostname.endsWith(']')) {
return hostname.slice(1, -1);
}
return hostname;
}
function isBlockedIp(hostname: string): boolean {
try {
const addr = ipaddr.parse(normalizeIpLiteral(hostname));
return addr.range() !== 'unicast';
} catch {
return false;
}
}
range() !== 'unicast' は、プライベート IP だけでなく、通常のユニキャストとして扱わない範囲を広めに拒否する判定です。ただし、これは完成したサーバーサイドリクエストフォージェリ対策ではありません。通常のユニキャストに分類されるアドレスでも、サービスの要件によっては拒否すべき場合があります。
実運用では、許可する接続先を明示するか、拒否する CIDR を match() や subnetMatch() で管理します。どこまで拒否するかは、サービスの要件に合わせて決めます。
IPv6 リテラルも考慮します。URL の hostname は角括弧付きになるため、ipaddr.parse() へ渡す前に外します。
new URL('http://[::1]').hostname
// '[::1]'
判定には、入力文字列ではなく、new URL() で解析した後の hostname を渡します。
const url = new URL('http://2130706433');
isBlockedIp(url.hostname)
// true
解析後の意味による判定
今回の面白さは、IPv4 に珍しい書き方があることだけではありません。重要なのは、アプリが文字列をどう見たかではなく、実際に通信へ使う層がどのように解釈するかです。
同じ原則は、ほかの入力処理にも現れます。
- IDN(国際化ドメイン名)は、A-label に正規化してから比較する
- パスは、ドットセグメントやエンコードを考慮してから許可範囲を確認する
- メールアドレスやファイル名も、保存・比較・実行で解釈がずれないようにする
URL のホスト名入力については、この記事では IPv4 正規化に絞ります。IDN(国際化ドメイン名)や new URL('http://' + input) の寛容な解析については、別記事で整理しています。
サーバーサイドリクエストフォージェリ対策としては入力時の確認だけでは足りない
ここまでの説明は、URL に IP アドレスが直接書かれている場合の対策です。
入力された URL が通常のホスト名でも、DNS の結果が内部 IP アドレスになることがあります。さらに、検証後に DNS 応答を切り替える DNS リバインディングにも注意が必要です。たとえば、あるホスト名が最初は外部 IP アドレスを返し、後から 127.0.0.1 を返すように切り替わる可能性があります。
サーバーサイドリクエストフォージェリ対策では、少なくとも次の段階で確認します。
1. 入力時:
URL 文字列や IP リテラルを検証する
2. DNS 解決時:
解決された IP アドレスが内部アドレスでないか確認する
3. 接続時:
実際に接続した先の IP アドレスも確認する
HTTP リダイレクトを許可する場合は、遷移先についても同じ確認が必要です。
この記事で扱っているのは、主に 1 つ目の「入力時」の対策です。
まとめ
127.0.0.1 は、2130706433 や 0x7f000001 のようにも書けます。
入力文字列に IPv4 用の正規表現を直接当てるだけでは、変則表記を見逃します。
1. new URL() で URL として解析する
2. url.hostname を取り出す
3. 正規化後の hostname を標準 API やライブラリで判定する
この順序は、単なる IPv4 対策ではありません。
文字列を検証する前に、実際に使う解析器で同じ意味にそろえる。URL を扱う境界では、この順序を意識しておくと、見落としを減らせます。
ただし、これはサーバーサイドリクエストフォージェリ対策の一部です。DNS 解決後、接続時、リダイレクト先の確認も必要です。
参考情報
- WHATWG URL Standard
- WHATWG URL Standard: IPv4 parser
- WHATWG URL Standard: IPv4 number parser
- Node.js 公式ドキュメント: WHATWG URL API
- Node.js 公式ドキュメント:
net.isIP() - MDN: URL
- OWASP: Server Side Request Forgery Prevention Cheat Sheet
- RFC 1918: Address Allocation for Private Internets
- RFC 6890: Special-Purpose IP Address Registries
- ipaddr.js 公式リポジトリ