SSRF 対策

アプリケーションが利用者から渡された URL をダウンロードするとき、攻撃者はそれを悪用してあなたの内部のネットワークに手を伸ばせます。UrlValidatorIPAddressのクラスは、こうした 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 です。

バージョン: 4.x