<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
БЕЗПЕКА · SENIOR

Що таке SSRF і як безпечно завантажувати дані за URL від користувача?

SSRF (server-side request forgery) трапляється, коли сервер іде за адресою, яку задав користувач, і стукає туди, куди сам користувач дістатись не може: у метадані хмари на `169.254.169.254`, у Redis на localhost чи в адмінку у внутрішній мережі. Захист: дозволяти лише `http(s)`, самим резолвити хост і відкидати непублічні адреси, підключатися саме до перевіреної IP, не йти за редіректами без повторної перевірки, а надійніше пустити такі запити через окремий egress-проксі.

Користувач вказує URL вебхука, і ми робимо на нього POST. Що може піти не так?
Аватар можна завантажити за посиланням. Як цим отримати ключі від AWS?
Ми перевіряємо, що хост не `localhost` і не `127.0.0.1`. Чому цього недостатньо?
Що таке DNS rebinding і як він обходить перевірку адреси?
SSRF вебхуки cloud metadata DNS rebinding curl egress

SSRF не вимагає від сервера нічого незвичного: він просто робить HTTP-запит за адресою, яку дав користувач. Небезпека в тому, звідки цей запит іде. Сервер стоїть у внутрішній мережі, бачить Redis на localhost, Elasticsearch без пароля, адмінку на 10.0.0.5 і, головне, сервіс метаданих хмари на 169.254.169.254, де в AWS з IMDSv1 лежать тимчасові ключі ролі інстансу. Вебхуки, аватар за посиланням, імпорт RSS, превʼю посилань, генератор PDF з HTML користувача: усе це точки входу. В OWASP Top 10:2021 SSRF був окремою категорією A10, а в редакції 2025 його включено до A01 Broken Access Control.

Перевірка рядка URL не працює, бо до однієї адреси веде безліч записів. 127.1, десятковий запис 2130706433, IPv6 [::1] або будь-який домен з A-записом на внутрішню IP. Тому перевіряють не рядок, а результат резолву: усі адреси хоста, через filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) з PHP 8.2, який відкидає приватні, зарезервовані, link-local і CGNAT-діапазони. Схему обмежують http і https: file_get_contents() охоче прочитає file:///etc/passwd чи php://filter, а curl без обмеження протоколів підтримує gopher://, dict:// та інші схеми, якими можна говорити з Redis чи іншими внутрішніми сервісами.

Далі дві пастки, на яких перевіряють Senior. Перша: редіректи. Перевірений публічний сервер відповідає 302 на http://169.254.169.254/..., і HTTP-клієнт іде туди сам, тому редіректи вимикають (Http::withoutRedirecting()) або перевіряють кожен крок. Друга: DNS rebinding. Якщо застосунок резолвить хост для перевірки, а Guzzle резолвить його вдруге для запиту, домен з TTL 0 може повернути різні адреси. Закривають тим, що підключаються саме до перевіреної IP через CURLOPT_RESOLVE; TLS-сертифікат і заголовок Host при цьому лишаються правильними.

Перевірки в коді легко зламати черговим рефакторингом, тому надійна відповідь закінчується мережевим рівнем. Вихідні запити за адресами користувачів робить окремий воркер через egress-проксі (наприклад, Smokescreen), який сам резолвить, сам блокує внутрішні діапазони і стоїть у сегменті, звідки внутрішніх сервісів не видно. На стороні хмари вмикають IMDSv2, де метадані вимагають PUT за токеном. І користувачу не повертають ні тіло відповіді, ні детальну помилку: навіть коди й час відповіді дозволяють просканувати внутрішню мережу, а «сліпий» SSRF усе одно лишається SSRF.

final class WebhookUrlGuard
{
    /** Повертає перевірену IP, до якої треба підключатися. */
    public function resolve(string $url): string
    {
        $parts = parse_url($url);

        if (! in_array($parts['scheme'] ?? null, ['http', 'https'], true) || empty($parts['host'])) {
            throw new InvalidArgumentException('Дозволені лише http і https.');
        }

        // Резолвимо самі. gethostbynamel повертає лише IPv4: хост без A-запису не пройде.
        $ips = gethostbynamel($parts['host']) ?: [];

        foreach ($ips as $ip) {
            // GLOBAL_RANGE (PHP 8.2+) відкидає 127/8, 10/8, 169.254/16, 100.64/10 тощо.
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) === false) {
                throw new InvalidArgumentException("Адреса {$ip} не публічна.");
            }
        }

        return $ips[0] ?? throw new InvalidArgumentException('Хост не резолвиться.');
    }
}

// Підключаємось до перевіреної IP і не йдемо за редіректами:
// інакше 302 на 169.254.169.254 або DNS rebinding обійдуть перевірку.
$ip = $guard->resolve($url);
$host = parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT) ?? (str_starts_with($url, 'https:') ? 443 : 80);

Http::withoutRedirecting()
    ->connectTimeout(2)
    ->timeout(5)
    ->withOptions(['curl' => [CURLOPT_RESOLVE => ["{$host}:{$port}:{$ip}"]]])
    ->post($url, $payload);
Що небезпека не в самому запиті, а в мережевій позиції сервера: він бачить внутрішні сервіси і метадані хмари, яких не бачить користувач.
Що перевірка рядка з хостом нічого не дає: `127.1`, `2130706433`, `[::1]`, домен з A-записом `127.0.0.1` і редірект ведуть туди ж.
Що перевіряти треба IP після резолву, усі адреси, і потім підключатися саме до перевіреної, інакше DNS rebinding підмінить адресу між перевіркою і запитом.
Що редіректи, схеми (`file://`, `gopher://`, `php://` у `file_get_contents()`) і обробники на зразок генераторів PDF теж є шляхами SSRF.
Що мережевий рівень (egress-проксі, IMDSv2, сегментація) надійніший за перевірки в коді, і сильна відповідь пропонує обидва.
Заборонити рядки `localhost` і `127.0.0.1` у URL: децимальна IP, скорочений запис чи будь-який домен з A-записом на `127.0.0.1` проходять.
Перевірити IP через `gethostbyname()`, а запит віддати Guzzle, який резолвить хост ще раз: між двома резолвами DNS rebinding повертає іншу адресу.
Залишити автоматичні редіректи: перевірений публічний хост відповідає `302 Location: http://169.254.169.254/...`, і клієнт слухняно йде туди.
Читати URL через `file_get_contents($url)`: обгортки потоків PHP приймають `file:///etc/passwd` і `php://filter`, HTTP для них лише один з варіантів.
Повертати користувачу тіло відповіді або детальну помилку: навіть без тіла різниця в часі й кодах дає змогу сканувати внутрішню мережу.
ПОРАДА

Почніть з того, що бачить сервер, а не користувач: метадані хмари, Redis, внутрішні API. Далі покажіть, чому перевірка рядка не працює (редірект, DNS), і закінчіть мережевим рівнем: egress-проксі з allowlist і IMDSv2. Такий порядок показує розуміння, а не знання чекліста.

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Між перевіркою і запитом хост можуть зарезолвити вдруге, і DNS rebinding поверне вже внутрішню адресу, тому підключаються до тієї IP, яку перевірили (`CURLOPT_RESOLVE`). Редірект з публічного хоста веде куди завгодно, зокрема на `169.254.169.254`. Рядкова заборона `localhost` не ловить `127.1`, `2130706433` чи домен з A-записом на `127.0.0.1`.