SSRF 対策
アプリケーションが利用者から渡された URL をダウンロードするとき、攻撃者はそれを悪用してあなたの内部のネットワークに手を伸ばせます。UrlValidatorと IPAddressのクラスは、こうした Server-Side Request Forgery(SSRF)の攻撃から守るのを助けます。
SSRF とは何か
利用者が URL を入力すると、あなたのサーバーがそれをダウンロードする機能を思い浮かべてください。遠くのアドレスからのアバター、webhook の宛先、リンクのプレビューなどです。害はなさそうに見えますが、そのアドレスに手を伸ばすのは利用者のブラウザではなくサーバーです。そしてサーバーは、攻撃者には見えない場所を見られます。ループバックのインターフェース、私設のネットワーク、クラウドのサービスです。
ですから攻撃者は、公のインターネットではなく内側を指す URL を送ります。よくある標的は次のとおりです。
http://169.254.169.254/のクラウドのメタデータ。アクセスの鍵が漏れることがありますhttp://192.168.1.1/のような内部の管理画面やルーターhttp://localhost:6379/の Redis のような、認証のないサービス
この種の弱点はとてもよくあるもので、OWASP Top 10にも数えられています。守り方は、取りに行く前に URL を検証し、公でないアドレスに解決されるものはすべて拒むことです。
UrlValidator
Nette\Http\UrlValidatorは、URL を設定できる方針と照らし合わせます。スキーム、ポート、ホスト、userinfo、そしてそのホストが解決される IP アドレスです。基本の使い方は 1 回の呼び出しです。
use Nette\Http\UrlValidator;
if (!(new UrlValidator)->allows($userUrl)) {
return; // 安全でない URL なので取りに行きません
}
既定の方針は意図して厳しく、ポート 443 の https で公の IP
アドレスを指すものだけを受け付けます。そのほか(ループバック、私設の範囲、クラウドのメタデータを含むリンクローカル、予約された範囲)はすべて拒まれ、マルチキャストは無条件に拒まれます。利用者から渡される任意の
URL を取りに行くなら、これが正しい出発点です。
方針の設定
方針はコンストラクタで形づくります。たとえば素の http
をどのポートでも許し、私設のアドレスにも届くようにするなら(信頼できるネットワークの中で役立ちます)次のようにします。
$validator = new UrlValidator(
schemes: ['http', 'https'],
ports: null, // どのポートでも
allowPrivateIps: true,
);
よくあるやり方は、ホストの許可の一覧を使って、取りに行く先を決まった提携先のドメインだけに絞ることです。*.
の接頭辞はどんな深さのサブドメインにも合いますが、頂点のドメインには合いません。必要なら両方を並べてください。
$validator = new UrlValidator(
hostAllowlist: ['example.com', '*.example.com'],
);
コンストラクタのオプションの一覧です。
| パラメータ | 既定 | 意味 |
|---|---|---|
schemes |
['https'] |
許されるスキーム。[] はすべてを拒みます |
ports |
[443] |
許されるポート。null
はどれでも。スキームから決まる暗黙のポートも認められます |
allowPrivateIps |
false |
私設の範囲を許します(10/8、172.16/12、192.168/16、fc00::/7) |
allowLoopback |
false |
ループバックを許します(127.0.0.0/8、::1) |
allowLinkLocal |
false |
クラウドのメタデータ 169.254.169.254 を含むリンクローカルを許します |
allowReserved |
false |
IANA が予約した範囲を許します |
allowUserinfo |
false |
URL の中の user:pass@ を許します |
hostAllowlist |
null |
設定すると、ホストはひとつの形に合わなければなりません。[]
はすべてを拒みます |
hostBlocklist |
null |
設定すると、ホストはどの形にも合ってはいけません |
検証のメソッド
この検証器には 3 つのメソッドがあります。allows() は DNS
の解決も含めた完全な確認を行います。ホストが解決され、すべての A/AAAA のアドレスが IP
の方針を通らなければなりません。
(new UrlValidator)->allows($url); // bool
allowsWithoutDns() は DNS の解決と IP
の範囲の確認を飛ばします。速い事前の絞り込みとして、あるいは DNS
の検証を取得の層に任せている場合に使ってください。
(new UrlValidator)->allowsWithoutDns($url); // bool
どちらのメソッドも、文字列、UrlImmutableオブジェクト、null(これはいつも失敗します)を受け取ります。
DNS リバインディングを封じる
検証と取得のあいだには微妙な競争があります。攻撃者はホストを検証するときには安全な IP
を返し、実際のダウンロードのときには DNS を内部の IP
に切り替えられます。この穴をふさぐために getResolvedIPs() は検証を通った IP
アドレスを返すので、接続をそれらに固定すれば、取得がよそへ向け直されることはありません。
$ips = (new UrlValidator)->getResolvedIPs($url);
if (!$ips) {
return; // 安全でない URL
}
$ch = curl_init($url);
$host = parse_url($url, PHP_URL_HOST);
curl_setopt($ch, CURLOPT_RESOLVE, ["$host:443:" . implode(',', $ips)]);
// ... リクエストを実行します
このメソッドは、完全な方針を通った IP の文字列の配列(先に A レコード、次に AAAA)を返します。何かに失敗すると空の配列を返します。URL の中に IP そのものが書かれている場合は、そのアドレスを直接検証し、DNS の問い合わせは行いません。
IPAddress
Nette\Http\IPAddressは、IPv4 と IPv6
のアドレスを扱う変更できない値のオブジェクトです。UrlValidator
が内部で使っていますが、アドレスを分類するときにはそれ自体でも便利です。コンストラクタは正しくないアドレスに対して
Nette\InvalidArgumentException を投げます。
use Nette\Http\IPAddress;
$ip = new IPAddress('169.254.169.254');
echo $ip; // '169.254.169.254'
例外が欲しくないなら、tryFrom() のファクトリか isValid()
の確認を使います。
$ip = IPAddress::tryFrom($input); // ?IPAddress
IPAddress::isValid($input); // bool
アドレスの分類
これらの述語は、アドレスがどの種類に属するかを教えてくれます。大事なのは
isPublic() で、公に経路のあるアドレスにだけ true になります。これはまさに SSRF
の守り手が求めるものです。
$ip = new IPAddress('169.254.169.254');
$ip->isPublic(); // false
$ip->isLinkLocal(); // true(クラウドのメタデータの範囲)
述語の一覧です。
| メソッド | 調べる対象 |
|---|---|
isPublic() |
公に経路がある(下のどれでもない) |
isPrivate() |
RFC 1918 / 4193 の私設の範囲 |
isLoopback() |
127.0.0.0/8、::1 |
isLinkLocal() |
169.254.0.0/16(クラウドのメタデータを含む)、fe80::/10 |
isMulticast() |
224.0.0.0/4、ff00::/8 |
isReserved() |
IANA が予約したもの(文書用、CGNAT、将来のための予約など) |
範囲に含まれるか
isInRange() は、そのアドレスが CIDR
のブロックに入るかを調べます。接頭辞の付いたネットワークも、ちょうど一致させるための裸のアドレスも渡せます(IPv4
なら暗黙の /32、IPv6 なら /128)。
$ip = new IPAddress('192.168.1.50');
$ip->isInRange('192.168.0.0/16'); // true
$ip->isInRange('10.0.0.1'); // false(ちょうどの一致)
形の崩れた入力や、違う IP の系統は false を返します。
IPv4 を写した IPv6
IPv4 を写した IPv6(::ffff:127.0.0.1
など)として書かれたアドレスは、素朴なフィルタをすり抜ける古典的な手です。IPAddress
はそれを整えるので、範囲の述語はその変装を見抜きます。
$ip = new IPAddress('::ffff:127.0.0.1');
$ip->isLoopback(); // true
$ip->isIPv4Mapped(); // true
$ip->toIPv4(); // IPAddress('127.0.0.1')
isIPv4() と isIPv6()
のメソッドは文字としての形を伝えます。写されたアドレスは IPv4 ではなく IPv6 です。