Nette Documentation Preview

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