Защита от SSRF в PHP: как безопасно открывать чужие адреса

0

Безопасность ♡

У меня на портале есть инструменты, которые сами лезут на чужие сайты: проверить 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 по порядку

  1. Разбирает адрес заново. Допустимы только http и https, только порты 80 и 443, логин с паролем в адресе запрещены. Домен переводится в Punycode, каждая его часть проверяется регулярным выражением.
  2. Превращает домен в IP. Спрашиваю и A-, и AAAA-записи, чтобы IPv6 не стал чёрным ходом.
  3. Проверяет все полученные адреса, а не только первый. Стоит хоть одному оказаться во внутренней сети, и запрос отклоняется.
  4. Отдаёт curl уже проверенный IP, так что запрос уходит именно на него, а не на имя домена.
  5. Редиректы ведёт сама, и каждый переход снова проходит все эти проверки.

Кого не пускаем

Проход закрыт по двум спискам: 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-- перед всей этой проверкой, я рассказывала отдельно.

Комментарии

Буду рада вашим мыслям, идеям и вопросам!
Давайте обсуждать ♡

Пока никто не написал.
Станьте первым! ♡