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
2
import ipaddress
ipaddress.ip_address('127.1') # ValueError

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// allow.mjs(Node.js v24.11.1 で確認)
const ALLOWED = new Set(['good.example']);

function checkedUrl(input) {
const u = new URL(input);
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('scheme');
if (u.username || u.password) throw new Error('userinfo');
if (!ALLOWED.has(u.hostname)) throw new Error('host ' + u.hostname);
return u.href; // fetch にはこれを渡す
}

for (const s of [
'http://evil.example\\@good.example/',
'HTTP://Good.Example:80/a/../b',
'http://good.example@evil.example/',
]) {
try { console.log('OK', checkedUrl(s)); }
catch (e) { console.log('NG', JSON.stringify(s), e.message); }
}
1
2
3
4
$ node allow.mjs
NG "http://evil.example\\@good.example/" host evil.example
OK http://good.example/b
NG "http://good.example@evil.example/" userinfo

判定をどうしても Python で行い、接続は別の実装が行う場合は、どちらのパーサーでも同じ結果になる URL だけを通す。解釈が分かれる書き方を、入口で拒否するということである。

実際に試す

前提: Python 3.11.4 以降(確認は 3.11.9)。外部パッケージは不要で、ネットワークにも接続しない。次のコードは、上で食い違った書き方をすべて拒否する。

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
40
41
42
43
44
45
# strict_host.py
import re
import sys
from urllib.parse import urlsplit

sys.stdout.reconfigure(encoding='utf-8')

LABEL = r'[a-z0-9](?:[a-z0-9-]*[a-z0-9])?'
HOST_RE = re.compile(rf'{LABEL}(?:\.{LABEL})+')
NUMERIC_LAST = re.compile(r'0x[0-9a-f]*|[0-9]+')

def strict_hostname(url):
if url != url.strip() or any(c in url for c in '\\\t\r\n'):
raise ValueError('空白・タブ・改行・バックスラッシュを含む')
u = urlsplit(url)
if u.scheme not in ('http', 'https'):
raise ValueError('http/https 以外')
if '@' in u.netloc:
raise ValueError('userinfo(@)を含む')
host = u.hostname
if host is None or not HOST_RE.fullmatch(host):
raise ValueError('ASCII のドメイン名ではない')
if NUMERIC_LAST.fullmatch(host.rsplit('.', 1)[-1]):
raise ValueError('末尾ラベルが数字(IPv4 として解釈される)')
return host

cases = [
'http://evil.example\\@good.example/',
'http://good.example\t.evil.example/',
' http://good.example/',
'HTTP://Good.Example:80/a/../b',
'http://127.1/',
'http://0x7f.0.0.1/',
'http://2130706433/',
'http://%65vil.example/',
'http://evil.example/',
'http://evil。example/',
'http://good.example@evil.example/',
'https://good.example/path?q=1',
]
for s in cases:
try:
print('OK ', repr(s), '->', strict_hostname(s))
except ValueError as e:
print('NG ', repr(s), '->', e)
1
2
3
4
5
6
7
8
9
10
11
12
13
$ python strict_host.py
NG 'http://evil.example\\@good.example/' -> 空白・タブ・改行・バックスラッシュを含む
NG 'http://good.example\t.evil.example/' -> 空白・タブ・改行・バックスラッシュを含む
NG ' http://good.example/' -> 空白・タブ・改行・バックスラッシュを含む
OK 'HTTP://Good.Example:80/a/../b' -> good.example
NG 'http://127.1/' -> 末尾ラベルが数字(IPv4 として解釈される)
NG 'http://0x7f.0.0.1/' -> 末尾ラベルが数字(IPv4 として解釈される)
NG 'http://2130706433/' -> ASCII のドメイン名ではない
NG 'http://%65vil.example/' -> ASCII のドメイン名ではない
NG 'http://evil.example/' -> ASCII のドメイン名ではない
NG 'http://evil。example/' -> ASCII のドメイン名ではない
NG 'http://good.example@evil.example/' -> userinfo(@)を含む
OK 'https://good.example/path?q=1' -> good.example

ドットを含まない 2130706433 は、末尾ラベルの判定より前に、ドメイン名の形(ドットを 1 つ以上含む)で弾かれる。末尾ラベルが数字かどうかの判定は、WHATWG の ends in a number を簡略にしたものである。この関数は IP アドレス表記のホストをすべて拒否する。IP アドレスを許す必要がある場合は、ipaddress で厳密に読める表記だけを別に許可すること。

食い違いの表そのものを再現するには、次の 2 つを同じ URL の一覧で実行すればよい。

1
2
3
// Node.js
console.log(new URL('http://evil.example\\@good.example/').hostname); // evil.example
console.log(new URL('http://127.1/').hostname); // 127.0.0.1
1
2
3
4
# Python
from urllib.parse import urlsplit
print(urlsplit('http://evil.example\\@good.example/').hostname) # good.example
print(urlsplit('http://127.1/').hostname) # 127.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

new URLとurlsplitでホスト名が食い違う7つのURL
https://blog.hashito.biz/2026/11/07/whatwg-new-url-python-urlsplit-hostname-mismatch-backslash-ipv4/
著者
hashito
作成日
2026年11月7日
著作権