Защита от SSRF в PHP: как безопасно открывать чужие адреса
Безопасность ♡
У меня на портале есть инструменты, которые сами лезут на чужие сайты: проверить robots.txt, sitemap, скорость ответа. Ты вводишь адрес, сервер бежит по нему и докладывает, что нашёл. Милота, пока не вспомнишь одну деталь: адрес вводит кто угодно, а ходит по нему мой сервер, у которого доступов куда больше, чем у любого посетителя. Классика жанра: «мам, а можно я введу 127.0.0.1?»
Чем это грозит
Если вместо адреса сайта в форму вписать 127.0.0.1 или адрес из внутренней сети, сервер послушно постучится сам к себе и всё расскажет. Так можно выяснить, какие сервисы на нём крутятся и что лежит во внутренней сети. А в облаках есть отдельный подарок: служебный адрес 169.254.169.254, где хранятся метаданные виртуалки, и для взломщика это золотая жила.
Эта дырка называется SSRF (server-side request forgery). Чтобы её закрыть, я вообще не вызываю curl напрямую: все запросы к чужим адресам идут через отдельный класс SafeHttp, который у меня работает как злой охранник на входе.
Почему «просто проверить строку» не работает
Первая мысль у всех одна: запретить слово localhost и цифры 127. Ну и, конечно, это ломается за пять минут. Один и тот же IP записывается десятком способов, а безобидный на вид домен может вести прямиком на 127.0.0.1. Знакомая картина: у тебя на локалке всё работает, а на проде выясняется, что нет.
Значит, проверять надо не то, что человек ввёл, а то, куда его ввод в итоге приведёт. Поэтому SafeHttp сам превращает домен в IP, проверяет получившийся адрес и только потом делает запрос.
Что делает SafeHttp по порядку
- Разбирает адрес заново. Допустимы только http и https, только порты 80 и 443, логин с паролем в адресе запрещены. Домен переводится в Punycode, каждая его часть проверяется регулярным выражением.
- Превращает домен в IP. Спрашиваю и A-, и AAAA-записи, чтобы IPv6 не стал чёрным ходом.
- Проверяет все полученные адреса, а не только первый. Стоит хоть одному оказаться во внутренней сети, и запрос отклоняется.
- Отдаёт curl уже проверенный IP, так что запрос уходит именно на него, а не на имя домена.
- Редиректы ведёт сама, и каждый переход снова проходит все эти проверки.
Кого не пускаем
Проход закрыт по двум спискам: 14 диапазонов IPv4 и 9 диапазонов IPv6. Туда попали локальный адрес, частные сети 10.0.0.0/8, 172.16.0.0/12 и 192.168.0.0/16, link-local 169.254.0.0/16, адреса общего оператора 100.64.0.0/10, многоадресные и зарезервированные диапазоны. Из IPv6 это, например, ::1, fc00::/7 и fe80::/10. Короче, «You shall not pass» для всей внутренней инфраструктуры.
Есть один подвох, от которого я сначала чуть не выпала из кресла. Адрес ::ffff:127.0.0.1 это обычный IPv4 в костюме IPv6. Если не сорвать с него костюм, он спокойно пройдёт мимо списка IPv4 и будет считаться публичным. Поэтому в isPublicIp() обёртку снимают до проверки:
if (strlen($binary) === 16 && str_starts_with($binary, str_repeat("\0", 10)."\xff\xff")) {
$binary = substr($binary, 12);
}
Что такое DNS rebinding, если по-простому
Допустим, я честно проверила IP домена, а потом отдала curl тот же домен. Curl снова спросит DNS, а тот ответит уже другое: в первый раз публичный адрес, во второй внутренний. Атакующему для этого достаточно управлять своим DNS-сервером. Получается ситуация в духе «This is fine»: проверка пройдена, а пожар уже внутри. Лечится следующим шагом.
Приколачиваю проверенный адрес гвоздями
В curl есть опция CURLOPT_RESOLVE: она сообщает, какому IP соответствует пара «домен:порт». Запрос идёт по исходному адресу, заголовок Host и проверка сертификата остаются прежними, но соединение открывается ровно с тем IP, который я уже проверила. Второго похода в DNS не будет, и подменить ответ никто не успеет.
$pin = $ips[0];
$pin = str_contains($pin, ':') ? '['.$pin.']' : $pin;
curl_setopt_array($handle, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_RESOLVE => [$host.':'.$port.':'.$pin],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
Обрати внимание на CURLOPT_FOLLOWLOCATION => false: автоматические редиректы я выключила специально, и сейчас объясню, зачем.
Редиректы: каждый прыжок как первый раз
Чужой сайт может ответить редиректом на внутренний адрес, вроде Location: http://127.0.0.1/admin. Если позволить curl идти по нему самому, он пойдёт, и все мои проверки превратятся в декорацию. Поэтому редиректы веду я: цикл делает не больше 5 переходов, и на каждом шаге адрес снова нормализуется, домен резолвится, IP проверяется.
for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
$response = $this->single($method, $current, $body, $options);
$location = $response->header('location');
if ($follow && $location !== '' && in_array($response->status, [301, 302, 303, 307, 308], true)) {
$current = self::normalizeUrl(self::absolute($current, $location));
continue;
}
return $response;
}
Ответ на POST при коде 301, 302 или 303 превращается в GET без тела, как это делают браузеры.
Лимиты, чтобы чужой сайт не съел сервер
Даже с проверенным адресом чужой сайт может тормозить или отдавать бесконечный ответ, поэтому запрос ещё и зажат в рамки. На соединение даётся 6 секунд, на весь запрос 12. Читаю не больше 2 МБ: функция записи возвращает 0, как только лимит выбран, и curl обрывает загрузку. Обрезанный ответ помечается флагом truncated, но ошибкой не считается.
Сертификат проверяется, протоколы только HTTP и HTTPS, так что file:// и gopher:// идут лесом.
Что осталось за кадром
Списки диапазонов придётся обновлять, если появятся новые зарезервированные сети. Вся защита от DNS rebinding держится на закреплении IP: уберёшь CURLOPT_RESOLVE, и окно между проверкой и запросом откроется обратно. Так что, если есть возможность, закрой ещё и файрволом исходящие соединения во внутренние диапазоны. Ремень плюс подтяжки, паранойя тут вполне здоровая.
В тестах клиент спрятан за интерфейсом Fetcher: вместо настоящего подставляется FakeFetcher, и в интернет тесты не бегают. А про то, как кириллические домены превращаются в xn-- перед всей этой проверкой, я рассказывала отдельно.
Комментарии
Буду рада вашим мыслям, идеям и вопросам!
Давайте обсуждать ♡
Делитесь
своими мыслями
— мне очень
это важно ♡