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