Защита от 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.