はじめに
ユーザーに example.com のようなホスト名だけを入力してもらい、アプリ側で正規化して保存したいことがあります。
そのとき、次のようなコードを書きたくなることがあります。
function extractHostname(input: string): string {
const url = new URL('http://' + input);
return url.hostname;
}
普通の入力なら期待どおりに動きます。
extractHostname('example.com'); // 'example.com'
extractHostname('shop.example.com'); // 'shop.example.com'
しかし、この実装を「ホスト名だけを受け付けるバリデーション」として使うには注意が必要です。
URL コンストラクタ は、入力を厳密に拒否するよりも、URL として解釈できる形に寄せて解析します。
たとえば、次のような入力でも hostname が返ります。
extractHostname('////attacker.com'); // 'attacker.com'
extractHostname('/path'); // 'path'
extractHostname('@evil.com'); // 'evil.com'
extractHostname('\\\\share'); // 'share'
これらは、URL として解釈すればホスト名を取り出せます。
ただし、ホスト名だけを入力してもらう欄なら、このような値は最初から対象外にしたいことがあります。問題は「最終的にどのホスト名になるか」だけではなく、ホスト名だけを受け付けるはずの境界で、URL コンストラクタが入力の意味を広げてしまうことです。
この記事では、URL コンストラクタを正規化には使いつつ、ホスト名入力のバリデーターとしては使わない、という分け方を整理します。
なぜこれが問題になるのか
////attacker.com を入力した人が、結果的に attacker.com として扱われるだけなら、その人が損をするだけに見えるかもしれません。
実際、この挙動だけで常に深刻な脆弱性になるわけではありません。重要なのは、次のような場面です。
ホスト名だけを受け付ける
↓
正規化して保存する
↓
保存値を、許可リスト・接続先・所有確認・監査ログなどに使う
このとき、入力欄の契約は「ホスト名だけ」です。/、@、?、# などを含む値は、URL としては解釈できても、ホスト名入力としては別物です。
もし URL コンストラクタだけに任せると、アプリが受け付ける入力の範囲が、利用者やレビュアーが想定している範囲より広くなります。
たとえば、次のようなズレが起きます。
入力としては:
////attacker.com
保存・比較に使われる値は:
attacker.com
「最終的なホスト名が同じならよい」と判断できる場面もあります。一方で、ホスト名だけの入力欄、許可リスト、所有確認、接続先制御のように、入力形式自体を制限したい場面では、この自動補正は望ましくありません。
この記事のポイントは、URL を使うな、ではありません。
URL コンストラクタは正規化に使う。ただし、どんな入力をホスト名として受け付けるかは、URL コンストラクタに渡す前に決める、という話です。
この挙動は、Node.js だけの独自仕様ではありません。WHATWG URL Standard に沿った URL の解析です。Node.js で使う場合も、URL は WHATWG URL API として提供されています。
結論
ユーザー入力を「ホスト名だけ」として受け取りたい場合は、URL コンストラクタに渡す前に、URL として特別な意味を持つ文字を拒否します。
たとえば、次のような文字です。
/
\
@
:
?
#
空白
この記事では「ホスト名だけ」を受け取る前提にするため、example.com:8080 のようなポート付き入力も拒否します。ポートも受け取りたい場合は、別の関数として設計します。
実装例です。
const FORBIDDEN_HOSTNAME_CHARS = /[/\\@:?#\s]/;
export function extractHostname(input: string): string | null {
const stripped = input.trim();
if (!stripped) {
return null;
}
// ホスト名だけを受け取りたいので、
// URL として意味を持つ区切り文字は先に拒否する。
if (FORBIDDEN_HOSTNAME_CHARS.test(stripped)) {
return null;
}
let url: URL;
try {
url = new URL('http://' + stripped);
} catch {
return null;
}
if (!url.hostname) {
return null;
}
// 事前バリデーションだけに依存しないように、
// ユーザー情報、ポート、パスなどが混ざっていないかも確認する。
if (
url.username !== '' ||
url.password !== '' ||
url.port !== '' ||
url.pathname !== '/' ||
url.search !== '' ||
url.hash !== ''
) {
return null;
}
return url.hostname;
}
このコードでは、URL コンストラクタに渡す前のバリデーションと、渡した後のフィールド確認を両方行っています。
事前に / や @ を拒否するのは、ホスト名入力として受け付ける範囲を狭く保つためです。事後に username、pathname、port などを見るのは、実装変更や見落としがあっても、ユーザー情報、ポート、パスが混ざった値を通しにくくするためです。
ポイントは、URL コンストラクタを使わないことではありません。
URL コンストラクタは便利です。
ただし、ユーザー入力をホスト名として扱う場合は、URL コンストラクタに渡す前のバリデーションが必要です。
なぜ ////attacker.com が attacker.com になるのか
次の呼び出しを考えます。
extractHostname('////attacker.com');
内部では、次の文字列を URL コンストラクタに渡しています。
http://////attacker.com
URL コンストラクタは、この入力を URL として解釈します。
その結果、余分なスラッシュが整理され、attacker.com がホスト名として扱われます。
new URL('http://////attacker.com').hostname;
// 'attacker.com'
これは URL コンストラクタのバグではありません。
URL コンストラクタが、入力を URL として解釈した結果です。
ただし、ホスト名だけを入力してもらう画面では、////attacker.com を受け付けたいわけではありません。
そのため、URL コンストラクタに渡す前に / を含む入力を拒否します。
@evil.com や /path でも同じことが起きる
@ は、URL ではユーザー情報とホスト名を分ける文字です。
http://user:password@example.com
そのため、次の入力も URL としては解釈できます。
new URL('http://@evil.com').hostname;
// 'evil.com'
@ の左側が空のユーザー情報、右側がホスト名として扱われます。
同じように、/path も注意が必要です。
new URL('http:///path').hostname;
// 'path'
開発者から見ると /path はパスのように見えます。
しかし、http:// と連結した結果、URL コンストラクタは path をホスト名として扱います。
URL コンストラクタでは、バックスラッシュがスラッシュに近い形で扱われることがあります。
extractHostname('\\\\share');
// 'share'
そのため、ホスト名だけを受け取りたい場合は、/、\、@ などを事前に拒否します。
正規化とバリデーションは分ける
URL.hostname は、ホスト名の正規化には便利です。
たとえば、IDN(国際化ドメイン名) を ASCII 表記へ変換できます。
new URL('http://日本語.jp').hostname;
// 'xn--wgv71a119e.jp'
この xn-- で始まる表記には、Punycode が使われます。
大文字小文字も正規化されます。
new URL('http://EXAMPLE.COM').hostname;
// 'example.com'
そのため、保存値や比較キーをそろえる用途では便利です。
一方で、URL.hostname は「入力がホスト名として受け付けてよい形式か」を完全に判断するものではありません。
役割を分けると、こうです。
URL.hostname:
URL として解析された結果のホスト名を正規化する
バリデーション:
そもそもホスト名だけの入力として受け付けてよいか判断する
この 2 つを混ぜない方が安全です。
なお、IDN(国際化ドメイン名)の A-label 化や、見た目が似た文字を使うドメイン名への注意点は、この記事では深掘りしません。別記事で整理しています。
また、SSRF 対策まで考える場合は、IPv4 の数値表記にも注意が必要です。URL コンストラクタは 2130706433 や 0x7f000001 のような入力を 127.0.0.1 に正規化することがあります。この話は別記事で扱う予定です。
用途に応じたバリデーションを追加する
extractHostname() は、ホスト名だけの入力を受け付け、URL.hostname で正規化する関数です。
公開ドメイン名だけを受け付けたい場合は、その後に用途別のバリデーションを追加します。
たとえば、次の条件は要件によって変わります。
末尾ドットを許可するか
社内ドメインを許可するか
IP アドレスを許可するか
IDN(国際化ドメイン名)を許可するか
正規化と、用途に応じたバリデーションを分けておくと、要件が変わっても調整しやすくなります。
ライブラリを使う選択肢
実運用では、ここまでの最小実装だけで完結させず、用途に応じて既存ライブラリを組み合わせることもあります。
-
validator.js: ホスト名や URL の形式確認 -
tldts: Public Suffix List を使ったドメイン解析 -
ipaddr.js: IPv4、IPv6、CIDR の判定 -
normalize-url: URL 全体の正規化
ただし、URL の正規化、ホスト名の形式確認、SSRF 対策は別の責務です。ライブラリを使う場合も、どの入力を許可するかは用途に応じて決める必要があります。
この記事の extractHostname() は、ライブラリの代替ではありません。URL コンストラクタに渡す前のバリデーションが必要な理由を示すための最小例です。
まとめ
URL コンストラクタはホスト名の正規化に便利ですが、ホスト名だけをバリデーションする API ではありません。
この注意は、変わった入力をした人を助けるためのものではありません。ホスト名だけを受け付けるはずの境界で、URL コンストラクタが入力の意味を広げ、保存値や許可リスト、接続先制御に別の形で入ってくることを避けるためのものです。
ユーザー入力をホスト名として受け取る場合は、次の順序で扱います。
- URL として特別な意味を持つ文字を事前に拒否する
-
new URL()で解析し、URL.hostnameで正規化する - 用途に応じて、公開ドメイン名や IP アドレスの許可条件をバリデーションする