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

версия: 4.x