new URLとurlsplitでホスト名が食い違う7つのURL
URL の許可リスト判定を Python で書き、実際のリクエストは Node.js やブラウザが送る。こうした構成では、同じ URL 文字列から 2 つのパーサーが別のホスト名を取り出すことがある。Python が good.example と判定した URL に対して、Node.js が evil.example へ接続してしまう。
ハシトシステムの URLパーサ は、ブラウザの new URL() で URL を分解して表示する。この記事では、同じ 12 個の URL を Node.js の new URL() と Python の urllib.parse.urlsplit() に通し、ホスト名を比べた。どこで食い違うかを、それぞれの仕様と突き合わせて説明する。最後に、判定を一本化する対策をコードで示す。
12 個の URL で比べる
Node.js v24.11.1 と Python 3.11.9(Windows 11)で、new URL(s).hostname と urlsplit(s).hostname を比べた。表中の \ はバックスラッシュ 1 文字を、<TAB> はタブ文字を表す。
| 入力 URL | new URL の hostname |
urlsplit の hostname |
一致 |
|---|---|---|---|
http://evil.example\@good.example/ |
evil.example |
good.example |
違う |
http://good.example<TAB>.evil.example/ |
good.example.evil.example |
good.example.evil.example |
同じ |
http://good.example/(先頭に空白) |
good.example |
good.example |
同じ |
HTTP://Good.Example:80/a/../b |
good.example |
good.example |
同じ |
http://127.1/ |
127.0.0.1 |
127.1 |
違う |
http://0x7f.0.0.1/ |
127.0.0.1 |
0x7f.0.0.1 |
違う |
http://2130706433/ |
127.0.0.1 |
2130706433 |
違う |
http://%65vil.example/ |
evil.example |
%65vil.example |
違う |
http://evil.example/(全角英字) |
evil.example |
evil.example |
違う |
http://evil。example/(全角句点) |
evil.example |
evil。example |
違う |
http://good.example@evil.example/ |
evil.example |
evil.example |
同じ |
http://good.example%2F@evil.example/ |
evil.example |
evil.example |
同じ |
12 個のうち 7 個でホスト名が食い違った。食い違いは 4 種類に分けられる。
1. バックスラッシュをスラッシュとして読むか
いちばん危ないのは 1 行目である。WHATWG URL Standard は、http や https などの special scheme では、U+005C(\)を / と同じに扱う。仕様は検証エラーの一覧で、この書き方を「special scheme なのに / の代わりに \ を使っている」誤りとして挙げている。ただし、パースは止めずに続ける。
そのため new URL() は evil.example\@good.example を evil.example と /@good.example/ に分ける。ホストは evil.example で、残りはパスになる。
urlsplit() は \ を区切りとして扱わない。// の後から次の / までをまとめて netloc にするので、netloc は evil.example\@good.example になる。hostname は最後の @ より後ろを取るので good.example になる。
Python 側で hostname == 'good.example' を確かめてから、URL 文字列をそのまま Node.js の fetch に渡すとする。判定は通り、接続先は evil.example になる。
2. 数字だけのホストを IPv4 として読むか
WHATWG のホストパーサーは、ホスト名の最後のラベルが数字なら(ends in a number)、そのホストを IPv4 アドレスとして解釈する。IPv4 の数値パーサーは 0x で始まる 16 進数と、0 で始まる 8 進数を受け付ける。部品が 4 つに満たない場合は、最後の部品が残りのバイトをまとめて表す。そのため 127.1、0x7f.0.0.1、2130706433 は、どれも 127.0.0.1 になる。
urlsplit() はホスト名を解釈しない。文字列をそのまま返す。Python の ipaddress.ip_address() に渡しても、3 つとも ValueError になる(実測)。
1 | |
SSRF 対策で「IP アドレスとして読めたら内部アドレスか調べる。読めなければドメイン名として許可する」と書いていると、127.1 はドメイン名扱いで通る。一方、Node.js はこの URL でループバックへ接続しにいく。
なお、OS の名前解決が 127.1 を受け付けるかどうかは環境で異なる。この Windows 11 の Python では、socket.getaddrinfo('127.1', 80) はエラーになった。
3. ホストのパーセントエンコードを戻すか
WHATWG のホストパーサーは、ホスト部分をまずパーセントデコードし、次に UTF-8 として読む。そのため %65vil.example は evil.example になる。urlsplit() は %65 を戻さずにそのまま返す。
4. 全角文字を ASCII に寄せるか
WHATWG はデコードしたホストに domain to ASCII(IDNA の処理)をかける。全角英字は半角に、全角句点「。」(U+3002)はドットに変換される。このため evil.example と evil。example は、どちらも evil.example になる。
urlsplit() の hostname は小文字化するだけで、全角文字はそのまま残る。ただし、Python 自身も名前解決のときは、文字列のホスト名を IDNA で符号化する(実測)。'evil.example'.encode('idna') と 'evil。example'.encode('idna') は、どちらも b'evil.example' になった。全角で書いた localhost を socket.getaddrinfo() に渡すと、::1 に解決された。つまり Python だけで完結する構成でも、文字列で判定したホスト名と、実際の接続先が一致しない。
日本語ドメインと Punycode の変換については、xn– ラベルと Punycode の記事 に書いた。
食い違わなかった 5 個
タブと改行は、両方とも取り除く。WHATWG は入力からすべての ASCII タブと改行を取り除く。Python のドキュメントにも、urlsplit() は「WHATWG の仕様の一部にならい」\n、\r、タブをどの位置からも取り除くとある。この変更は 3.10 で入った。先頭の C0 制御文字と空白を取り除く処理は 3.11.4 で入った。古い Python では、この 2 行も食い違う可能性がある。
@ を含む 2 行は、どちらのパーサーも最後の @ より後ろをホストとして読んだ。それでも、good.example@evil.example を目で見て good.example 宛てだと読み違えることはあるので、userinfo を含む URL は拒否するのが安全である。
大文字とデフォルトポートは、new URL() が正規化する。HTTP://Good.Example:80/a/../b の href は http://good.example/b になり、ポート 80 は消えて .. は解決される。urlsplit() は hostname だけを小文字にし、パスの /a/../b とポート 80 はそのまま残す。ホスト名は一致するが、パスで判定する処理なら同じように食い違う。
Python のドキュメントも「検証はしない」と書いている
Python の urllib.parse のドキュメントには「URL parsing security」という節がある。そこには、urlsplit() と urlparse() は入力を検証しないと書かれている。ほかのアプリケーションが不正とみなす入力でも、エラーを出さないことがある。セキュリティに関わる用途では、取り出した部品を信用する前に、自分のコードで確認するよう勧めている。
上の表は、その注意が具体的にどこで効いてくるかを示している。
対策: 判定と接続で同じパーサーを使う
いちばん確実なのは、判定と接続に同じパーサーを使うことである。さらに、パーサーが正規化して書き出した URL を接続に渡す。Node.js なら、new URL() で判定し、元の文字列ではなく href を fetch に渡す。
1 | |
1 | |
判定をどうしても Python で行い、接続は別の実装が行う場合は、どちらのパーサーでも同じ結果になる URL だけを通す。解釈が分かれる書き方を、入口で拒否するということである。
実際に試す
前提: Python 3.11.4 以降(確認は 3.11.9)。外部パッケージは不要で、ネットワークにも接続しない。次のコードは、上で食い違った書き方をすべて拒否する。
1 | |
1 | |
ドットを含まない 2130706433 は、末尾ラベルの判定より前に、ドメイン名の形(ドットを 1 つ以上含む)で弾かれる。末尾ラベルが数字かどうかの判定は、WHATWG の ends in a number を簡略にしたものである。この関数は IP アドレス表記のホストをすべて拒否する。IP アドレスを許す必要がある場合は、ipaddress で厳密に読める表記だけを別に許可すること。
食い違いの表そのものを再現するには、次の 2 つを同じ URL の一覧で実行すればよい。
1 | |
1 | |
結果は 2026-11-07 時点の Node.js v24.11.1 と Python 3.11.9 で得たものである。Python は版によってタブと先頭空白の扱いが変わるので、自分の環境の版で確かめてほしい。
ハシトシステムのURLパーサでは
URLパーサ は、入力された URL を new URL() で分解し、protocol・host・hostname・port・pathname・search・hash・origin とクエリパラメータを表に出す。2026-11-07 時点のコードはブラウザの new URL() をそのまま使っているので、ブラウザや Node.js がその URL をどう読むかを確かめられる。
上の表の 1 行目をツールに貼ると、hostname 欄には evil.example と出る。サーバー側の判定に Python などの別の実装を使っているなら、その実装が返すホスト名と見比べるとよい。スキームのない文字列は new URL() が受け付けないので、エラーとして表示される。
クエリの + と空白の扱いについては encodeURIComponent と URLSearchParams の記事 に書いた。
まとめ
- 12 個の URL のうち 7 個で、
new URL()とurlsplit()が返すホスト名が違った。 \は、WHATWG では special scheme の区切りになる。urlsplit()では区切りにならず、evil.example\@good.exampleを Python はgood.example、Node.js はevil.exampleと読む。127.1、0x7f.0.0.1、2130706433は、WHATWG では127.0.0.1になる。urlsplit()は文字列のまま返し、ipaddressはValueErrorを出す。- ホストの
%XXと全角文字は、WHATWG ではデコードされ ASCII に変換される。urlsplit()はそのまま返す。 - 判定と接続には同じパーサーを使い、正規化した
hrefを渡す。分けるしかない場合は、解釈が分かれる書き方を入口で拒否する。
参考
- WHATWG URL Standard — 3.5 Host parsing(IPv4 parser、ends in a number)、4.4 URL parsing(basic URL parser、special authority ignore slashes state)
- Python 3.11 ドキュメント — urllib.parse(urlsplit、URL parsing security)
- Python ドキュメント — ipaddress