SSRF 対策
*******
.[perex]
アプリケーションが利用者から渡された URL をダウンロードするとき、攻撃者はそれを悪用してあなたの内部のネットワークに手を伸ばせます。[#UrlValidator]と [#IPAddress]のクラスは、こうした Server-Side Request Forgery(SSRF)の攻撃から守るのを助けます。
→ [インストールと要件 |@home#インストール]
SSRF とは何か
=========
利用者が URL を入力すると、あなたのサーバーがそれをダウンロードする機能を思い浮かべてください。遠くのアドレスからのアバター、webhook の宛先、リンクのプレビューなどです。害はなさそうに見えますが、そのアドレスに手を伸ばすのは利用者のブラウザではなくサーバーです。そしてサーバーは、攻撃者には見えない場所を見られます。ループバックのインターフェース、私設のネットワーク、クラウドのサービスです。
ですから攻撃者は、公のインターネットではなく内側を指す URL を送ります。よくある標的は次のとおりです。
- `http://169.254.169.254/` のクラウドのメタデータ。アクセスの鍵が漏れることがあります
- `http://192.168.1.1/` のような内部の管理画面やルーター
- `http://localhost:6379/` の Redis のような、認証のないサービス
この種の弱点はとてもよくあるもので、[OWASP Top 10 |https://owasp.org/Top10/]にも数えられています。守り方は、取りに行く**前に** URL を検証し、公でないアドレスに解決されるものはすべて拒むことです。
UrlValidator
============
[api:Nette\Http\UrlValidator]は、URL を設定できる方針と照らし合わせます。スキーム、ポート、ホスト、userinfo、そしてそのホストが解決される IP アドレスです。基本の使い方は 1 回の呼び出しです。
```php
use Nette\Http\UrlValidator;
if (!(new UrlValidator)->allows($userUrl)) {
return; // 安全でない URL なので取りに行きません
}
```
既定の方針は意図して厳しく、ポート 443 の `https` で公の IP アドレスを指すものだけを受け付けます。そのほか(ループバック、私設の範囲、クラウドのメタデータを含むリンクローカル、予約された範囲)はすべて拒まれ、マルチキャストは無条件に拒まれます。利用者から渡される任意の URL を取りに行くなら、これが正しい出発点です。
方針の設定
-----
方針はコンストラクタで形づくります。たとえば素の `http` をどのポートでも許し、私設のアドレスにも届くようにするなら(信頼できるネットワークの中で役立ちます)次のようにします。
```php
$validator = new UrlValidator(
schemes: ['http', 'https'],
ports: null, // どのポートでも
allowPrivateIps: true,
);
```
よくあるやり方は、ホストの許可の一覧を使って、取りに行く先を決まった提携先のドメインだけに絞ることです。`*.` の接頭辞はどんな深さのサブドメインにも合いますが、頂点のドメインには合いません。必要なら両方を並べてください。
```php
$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 の方針を通らなければなりません。
```php
(new UrlValidator)->allows($url); // bool
```
`allowsWithoutDns()` は DNS の解決と IP の範囲の確認を飛ばします。速い事前の絞り込みとして、あるいは DNS の検証を取得の層に任せている場合に使ってください。
```php
(new UrlValidator)->allowsWithoutDns($url); // bool
```
どちらのメソッドも、文字列、[UrlImmutable |urls#UrlImmutable]オブジェクト、`null`(これはいつも失敗します)を受け取ります。
DNS リバインディングを封じる
----------------
検証と取得のあいだには微妙な競争があります。攻撃者はホストを検証するときには安全な IP を返し、実際のダウンロードのときには DNS を内部の IP に切り替えられます。この穴をふさぐために `getResolvedIPs()` は検証を通った IP アドレスを返すので、接続をそれらに固定すれば、取得がよそへ向け直されることはありません。
```php
$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
=========
[api:Nette\Http\IPAddress]は、IPv4 と IPv6 のアドレスを扱う変更できない値のオブジェクトです。`UrlValidator` が内部で使っていますが、アドレスを分類するときにはそれ自体でも便利です。コンストラクタは正しくないアドレスに対して `Nette\InvalidArgumentException` を投げます。
```php
use Nette\Http\IPAddress;
$ip = new IPAddress('169.254.169.254');
echo $ip; // '169.254.169.254'
```
例外が欲しくないなら、`tryFrom()` のファクトリか `isValid()` の確認を使います。
```php
$ip = IPAddress::tryFrom($input); // ?IPAddress
IPAddress::isValid($input); // bool
```
アドレスの分類
-------
これらの述語は、アドレスがどの種類に属するかを教えてくれます。大事なのは `isPublic()` で、公に経路のあるアドレスにだけ true になります。これはまさに SSRF の守り手が求めるものです。
```php
$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)。
```php
$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` はそれを整えるので、範囲の述語はその変装を見抜きます。
```php
$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 です。
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 です。