Защита от SSRF
**************
.[perex]
Когда ваше приложение скачивает URL, заданный пользователем, злоумышленник может злоупотребить этим, чтобы добраться до вашей внутренней сети. Классы [#UrlValidator] и [#IPAddress] помогают защититься от таких атак Server-Side Request Forgery (SSRF).
→ [Установка и требования |@home#Установка]
Что такое SSRF?
===============
Представьте себе возможность, при которой пользователь вводит URL, а ваш сервер его скачивает: аватар с удалённого адреса, цель вебхука, предпросмотр ссылки. Выглядит безобидно, но по адресу идёт сервер, а не браузер пользователя. А сервер видит места, которых злоумышленник не видит: интерфейс loopback, частную сеть, облачные сервисы.
Поэтому злоумышленник отправляет URL, который указывает внутрь, а не в публичный интернет. Типичные цели:
- метаданные облака по адресу `http://169.254.169.254/`, откуда могут утечь ключи доступа
- внутренние административные панели и маршрутизаторы вроде `http://192.168.1.1/`
- сервисы без аутентификации, например Redis по адресу `http://localhost:6379/`
Этот класс уязвимостей настолько распространён, что входит в [OWASP Top 10 |https://owasp.org/Top10/]. Защита состоит в том, чтобы проверить URL **до** того, как вы его скачаете, и отклонить всё, что разрешается в непубличный адрес.
UrlValidator
============
[api:Nette\Http\UrlValidator] проверяет URL по настраиваемой политике: схему, порт, хост, userinfo и IP-адреса, в которые хост разрешается. Базовое использование - один вызов:
```php
use Nette\Http\UrlValidator;
if (!(new UrlValidator)->allows($userUrl)) {
return; // небезопасный URL, не скачивайте его
}
```
Политика по умолчанию намеренно строгая: она принимает только `https` на порту 443, указывающий на публичный IP-адрес. Всё остальное (loopback, частные диапазоны, link-local, включая метаданные облака, зарезервированные диапазоны) отклоняется, а multicast отклоняется безусловно. Это правильная отправная точка для скачивания произвольных 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` | разрешить loopback (127.0.0.0/8, ::1)
| `allowLinkLocal` | `false` | разрешить link-local, включая метаданные облака 169.254.169.254
| `allowReserved` | `false` | разрешить диапазоны, зарезервированные IANA
| `allowUserinfo` | `false` | разрешить `user:pass@` в URL
| `hostAllowlist` | `null` | если задан, хост должен соответствовать одному из образцов; `[]` отклоняет все
| `hostBlocklist` | `null` | если задан, хост не должен соответствовать ни одному образцу
Методы проверки
---------------
Валидатор предлагает три метода. `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 rebinding
----------------------
Между проверкой и скачиванием есть тонкая гонка: злоумышленник может вернуть безопасный 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), прошедших полную политику, либо пустой массив при любой неудаче. Для IP-литерала в URL он проверяет адрес напрямую и запрос к 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()`: он истинен только для публично маршрутизируемых адресов, а именно этого и хочет защита от 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. Можно передать сеть с префиксом либо голый адрес для точного совпадения (неявно /32 для IPv4 и /128 для IPv6):
```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`.
IPv6, отображающий IPv4
-----------------------
Адреса, записанные как IPv4-mapped 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()` сообщают о текстовой форме: отображённый адрес - это IPv6, а не IPv4.
Защита от SSRF
Когда ваше приложение скачивает URL, заданный пользователем,
злоумышленник может злоупотребить этим, чтобы добраться до вашей
внутренней сети. Классы UrlValidator и IPAddress
помогают защититься от таких атак Server-Side Request Forgery (SSRF).
→ Установка и требования
Что такое SSRF?
Представьте себе возможность, при которой пользователь вводит URL, а
ваш сервер его скачивает: аватар с удалённого адреса, цель вебхука,
предпросмотр ссылки. Выглядит безобидно, но по адресу идёт сервер, а не
браузер пользователя. А сервер видит места, которых злоумышленник не
видит: интерфейс loopback, частную сеть, облачные сервисы.
Поэтому злоумышленник отправляет URL, который указывает внутрь, а не в
публичный интернет. Типичные цели:
- метаданные облака по адресу
http://169.254.169.254/, откуда могут утечь
ключи доступа
- внутренние административные панели и маршрутизаторы
вроде
http://192.168.1.1/
- сервисы без аутентификации, например Redis по
адресу
http://localhost:6379/
Этот класс уязвимостей настолько распространён, что входит в OWASP Top 10. Защита состоит в том, чтобы проверить URL
до того, как вы его скачаете, и отклонить всё, что разрешается в
непубличный адрес.
UrlValidator
Nette\Http\UrlValidator проверяет URL
по настраиваемой политике: схему, порт, хост, userinfo и IP-адреса, в которые
хост разрешается. Базовое использование – один вызов:
use Nette\Http\UrlValidator;
if (!(new UrlValidator)->allows($userUrl)) {
return; // небезопасный URL, не скачивайте его
}
Политика по умолчанию намеренно строгая: она принимает только
https на порту 443, указывающий на публичный IP-адрес. Всё остальное
(loopback, частные диапазоны, link-local, включая метаданные облака,
зарезервированные диапазоны) отклоняется, а multicast отклоняется
безусловно. Это правильная отправная точка для скачивания
произвольных 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 |
разрешить loopback (127.0.0.0/8, ::1) |
allowLinkLocal |
false |
разрешить link-local, включая метаданные облака 169.254.169.254 |
allowReserved |
false |
разрешить диапазоны, зарезервированные IANA |
allowUserinfo |
false |
разрешить user:pass@ в URL |
hostAllowlist |
null |
если задан, хост должен соответствовать одному из образцов; []
отклоняет все |
hostBlocklist |
null |
если задан, хост не должен соответствовать ни одному образцу |
Методы проверки
Валидатор предлагает три метода. allows() выполняет полную
проверку, включая разрешение DNS: хост разрешается, и каждый адрес
A/AAAA должен пройти политику IP:
(new UrlValidator)->allows($url); // bool
allowsWithoutDns() пропускает разрешение DNS и проверки диапазонов IP.
Используйте его как быстрый предварительный фильтр или когда проверка
DNS передана слою скачивания:
(new UrlValidator)->allowsWithoutDns($url); // bool
Оба метода принимают строку, объект UrlImmutable или null (который всегда
не проходит).
Борьба с DNS rebinding
Между проверкой и скачиванием есть тонкая гонка: злоумышленник может
вернуть безопасный 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),
прошедших полную политику, либо пустой массив при любой неудаче. Для
IP-литерала в URL он проверяет адрес напрямую и запрос к 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(): он истинен только для публично маршрутизируемых
адресов, а именно этого и хочет защита от 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. Можно передать
сеть с префиксом либо голый адрес для точного совпадения (неявно /32 для
IPv4 и /128 для IPv6):
$ip = new IPAddress('192.168.1.50');
$ip->isInRange('192.168.0.0/16'); // true
$ip->isInRange('10.0.0.1'); // false (точное совпадение)
Некорректный ввод или другое семейство IP возвращает false.
IPv6, отображающий IPv4
Адреса, записанные как IPv4-mapped 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() сообщают о текстовой форме:
отображённый адрес – это IPv6, а не IPv4.