Защита от SSRF для вебхуков, превью ссылок и загрузчиков URL
Как SSRF превращает вебхуки, превью ссылок и импортёры в путь к метаданным облака и внутренним сервисам, и какие защиты работают. С примером на Node.js.
· Чтение: 7 мин · Lina Source LLC
Любая функция, которая принимает URL от пользователя и загружает его с вашего сервера, — потенциальная подделка запроса на стороне сервера (SSRF, CWE-918). Вебхуки, превью ссылок, аватар по URL, импорт RSS и календарей, генераторы PDF и кнопки «импортировать по ссылке» устроены одинаково: ваш сервер выполняет запрос к адресу, который выбрал кто-то другой.
Проблема в том, где находится ваш сервер. Он дотягивается туда, куда не может атакующий: до сервиса метаданных облака, внутренних админ-панелей, баз данных без паролей в приватной сети и сервисов на localhost. SSRF превращает ваш сервер в прокси атакующего внутрь этой сети. Такую ошибку особенно легко допустить в небольших командах, потому что функция, которая её вызывает, выглядит безобидно: поле URL на странице настроек, карточка превью в чате, вспомогательный код, скачивающий аватар. Ничто из этого не похоже на код сетевой безопасности, поэтому его редко так и рецензируют.
Чего добивается атакующий
- Эндпоинты метаданных облака, самый известный из них — 169.254.169.254 в AWS, GCP и Azure, которые могут вернуть сведения об инстансе, а в некоторых конфигурациях и временные учётные данные для роли машины.
- Сервисы, привязанные к localhost: административные интерфейсы, отладочные серверы и эндпоинты метрик, рассчитанные только на локальных клиентов.
- Внутренние сервисы в приватных диапазонах (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), у которых нет аутентификации, потому что они никогда не предполагались доступными извне.
- Сканирование портов и обнаружение сервисов: по времени отклика или сообщениям об ошибках можно составить карту внутренней сети.
- Не-HTTP-протоколы, если библиотека загрузки поддерживает схемы вроде file:, gopher: или ftp:.
Даже когда ответ не возвращается атакующему, слепой SSRF всё равно может вызвать изменяющие состояние запросы к внутренним эндпоинтам. Вебхуки часто слепы, и это не делает их безопасными. Многие внутренние сервисы принимают простые GET-запросы, меняющие состояние, — сброс кеша или административные действия по ссылке, — именно потому, что предполагают: снаружи до них никто не дотянется.
Почему простые проверки не работают
Первое побуждение — разобрать URL и отклонять имена вроде localhost или строки, начинающиеся с 10. или 192.168. Это не работает по нескольким причинам.
- Имена хостов разрешаются в IP. Атакующий регистрирует домен, чья A-запись указывает на 127.0.0.1 или 169.254.169.254, и проверка имени хоста не видит ничего подозрительного.
- У IP-адресов много написаний: десятичное (2130706433), шестнадцатеричное, короткие формы вроде 127.1 и IPv4-адреса, отображённые в IPv6, например ::ffff:127.0.0.1. Сравнение строк их упускает.
- DNS rebinding: домен разрешается в публичный IP в момент проверки и в приватный — мгновением позже, когда подключается HTTP-клиент. Проверка и подключение — это два разных обращения к DNS.
- Редиректы: проверенный вами URL возвращает 302 на http://169.254.169.254/, и HTTP-клиент переходит по нему, не спрашивая вас.
Общее здесь одно: проверка выполняется не над тем адресом, к которому реально подключается сокет. Исправление — проверять разрешённый IP в момент подключения, для каждого подключения, в том числе после редиректов.
Защиты, от самой сильной к слабым
По возможности используйте список разрешённых адресов
Если функции нужно общаться только с известным набором хостов — например, интеграция с несколькими провайдерами, — внесите эти имена хостов в список разрешённых и отклоняйте всё остальное. Это самый надёжный контроль и самый простой для понимания. Сравнивайте разобранное имя хоста точно, а не через startsWith или endsWith: такие проверки пропускают похожие хосты вроде api.example.com.attacker.net или evilexample.com. Всё, что описано ниже, — для функций, которые обязаны принимать произвольные публичные URL.
Ограничьте схему и порт
Принимайте только https: (и http:, только если без него никак). Отклоняйте учётные данные в URL и ограничьте порты значениями 443 и 80, если нет явной необходимости в других. Разбирайте URL стандартным парсером, никогда не регулярным выражением; парсер WHATWG заодно нормализует нестандартные написания IP, что помогает последующим проверкам.
Разрешайте имя, затем проверяйте IP в момент подключения
Блокируйте loopback, приватные адреса, link-local, carrier-grade NAT, неопределённый адрес, а также IPv6-диапазоны unique-local и link-local. Главное — выполнять проверку внутри той функции разрешения имён, которую использует HTTP-клиент, чтобы проверялся именно тот адрес, к которому происходит подключение. Это закрывает брешь с DNS rebinding. Модули http и https в Node как раз принимают собственную функцию lookup.
Список блокировки ниже покрывает диапазоны, важные для большинства развёртываний. Добавьте публичные диапазоны, принадлежащие вашей собственной инфраструктуре: балансировщик или внутренний API с публичным IP всё равно может доверять запросам изнутри вашей сети. Отклоняйте имя хоста, если заблокирован любой из его разрешённых адресов, а не только первый, потому что клиент может пробовать их в любом порядке.
// safe-lookup.js
import dns from "node:dns";
import net from "node:net";
const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4-адреса, отображённые в IPv6
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");
export function isBlockedIp(address, family) {
return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}
// Проверяет каждый разрешённый адрес до подключения сокета
export function safeLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err);
const denied =
addresses.length === 0 ||
addresses.some((a) => isBlockedIp(a.address, a.family));
if (denied) {
return callback(new Error("Destination not allowed: " + hostname));
}
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
}Одну деталь легко упустить: когда URL содержит IP-литерал, Node подключается напрямую и вообще не вызывает lookup. Поэтому функция запроса должна сама проверять IP-литералы, прежде чем передавать управление дальше.
import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";
export function postWebhook(rawUrl, payload) {
const url = new URL(rawUrl);
if (url.protocol !== "https:") throw new Error("Only https is allowed");
if (url.port && url.port !== "443") throw new Error("Port not allowed");
if (url.username || url.password) throw new Error("Credentials not allowed");
const host = url.hostname.replace(/^\[|\]$/g, "");
const family = net.isIP(host);
if (family !== 0 && isBlockedIp(host, family)) {
throw new Error("Destination not allowed");
}
return new Promise((resolve, reject) => {
const req = https.request(
url,
{
method: "POST",
lookup: safeLookup,
timeout: 5000,
headers: { "content-type": "application/json" },
},
(res) => {
// node:https никогда не следует редиректам; 3xx считается ошибкой
res.resume();
resolve(res.statusCode);
}
);
req.on("timeout", () => req.destroy(new Error("Request timed out")));
req.on("error", reject);
req.end(JSON.stringify(payload));
});
}Если вы используете fetch в Node, который построен на undici, та же идея реализуется через собственный dispatcher: создайте undici Agent с опцией connect.lookup и передайте его как dispatcher. Принцип не меняется: проверка живёт там, где устанавливается соединение. Если исходящий трафик в вашей среде идёт через HTTP-прокси, учтите, что разрешение имени целевого хоста происходит тогда на прокси, а значит, те же правила должен применять сам прокси.
Отключите редиректы или проверяйте каждый переход заново
Для вебхуков не следуйте редиректам вовсе: получатель, который перенаправляет запрос, настроен неверно, и явная ошибка подскажет клиенту исправить свой эндпоинт. Для превью ссылок и импортёров, где редиректы нормальны, переходите по ним вручную с небольшим лимитом и прогоняйте каждый переход через те же проверки схемы, порта и IP. В fetch задайте redirect: «manual» и обрабатывайте заголовок Location сами.
Ограничьте то, что может успешный запрос
- Задавайте таймауты подключения и общего времени и ограничивайте размер ответа, чтобы загрузчик нельзя было использовать для удержания соединений открытыми или скачивания больших файлов.
- Не возвращайте пользователю сырые ответы и подробные сообщения об ошибках. Для превью ссылок возвращайте только извлечённые заголовок, описание и URL изображения.
- Удаляйте собственные заголовки аутентификации и cookie: загрузчик никогда не должен отправлять внутренние учётные данные на хосты, указанные пользователем.
Направьте трафик через egress-прокси
Самая надёжная схема выносит политику из кода приложения. Пропускайте исходящие запросы, инициированные пользователем, через выделенный egress-прокси или выполняйте их в изолированном воркере в сегменте сети, откуда просто нет маршрута к внутренним сервисам и эндпоинту метаданных. Тогда ошибка в проверке URL не превращается во взлом, потому что отказывает сама сеть. Открытые forward-прокси умеют применять правила назначения за вас, а отдельный воркер заодно изолирует медленные или враждебные ответы от основного веб-процесса.
Усильте сервис метаданных
В AWS требуйте IMDSv2, где нужен токен сессии, получаемый PUT-запросом, и держите лимит переходов равным 1, чтобы контейнеры не дотягивались до сервиса через хост. GCP и Azure требуют специальный заголовок запроса при обращении к метаданным. Это заметно поднимает планку для простого SSRF на основе GET, но это второй слой, а не замена блокировке link-local-адресов. Кроме того, выдавайте роли инстанса или сервиса только те права, которые нужны приложению, чтобы украденные через сервис метаданных учётные данные открывали как можно меньше.
Как проверить свои защиты
- Попробуйте http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ и http://169.254.169.254/latest/meta-data/ и убедитесь, что каждый адрес отклонён.
- Направьте подконтрольное вам имя хоста на 127.0.0.1 и убедитесь, что проверка в lookup его отклоняет. Затем задайте ему две A-записи, публичную и приватную, и убедитесь, что он по-прежнему отклоняется.
- Отдайте с публичного хоста редирект 302 на приватный адрес и убедитесь, что переход не выполняется.
- Попробуйте URL со схемами file:, ftp: и gopher:, а также URL с учётными данными или необычными портами.
При ревью кода ищите каждое место, где URL запроса строится из входных данных: fetch, axios, got, requests, http.Get, вызовы page.goto в headless-браузере и библиотеки обработки изображений, принимающие URL. CodeAuditAgent прослеживает этот поток данных в публичных репозиториях и вставленных фрагментах и сообщает об SSRF как о CWE-918, приводя цитату места вызова, сценарий эксплуатации и патч.