SSRF, ReDoS и path traversal: серверная поверхность атаки
Три серверные уязвимости Node: SSRF превращает загрузчик URL в зонд облачных метаданных; ReDoS замораживает event loop одной строкой; path traversal выходит из каталога через `../`. Фиксы: закрепи IP, ограничь регулярку, удержи путь.
Ты выкатил фичу превью ссылок: вставляешь URL, сервер его загружает и рисует OG-карточку. Через неделю твой счёт в AWS взрывается, а CloudTrail показывает, что твоя task-роль поднимает инстансы в регионе, которым ты не пользуешься. Весь след — одна строка HTTP-лога: кто-то вставил http://169.254.169.254/latest/meta-data/iam/security-credentials/preview-role, твой сервер послушно загрузил это, вернул временные IAM-учётки внутри карточки превью, и атакующий ушёл с ключами от твоего аккаунта. Никто не атаковал твою аутентификацию. Атаковали единственное место, где твой сервер делает запрос от имени незнакомца.
SSRF: когда сервер загружает то, что выбрал атакующий
Любая фича, где твой сервер загружает URL, который дал пользователь — вебхуки, прокси картинок, превью ссылок, рендереры PDF, «импорт по URL» — это примитив SSRF (Server-Side Request Forgery, подделка запроса на стороне сервера). Когда проектируешь фичу, делающую HTTP-запрос на основе пользовательского ввода, ты фактически даёшь атакующему прокси внутри твоей границы доверия. Атакующий не достаёт до цели — твой сервер делает это, изнутри твоей границы доверия, с твоей сетевой позиции и твоей облачной ролью. В этом весь эксплойт: запрос исходит с хоста, который дотягивается до того, до чего атакующий не может.
Главная цель — эндпойт облачных метаданных. В AWS это link-local-адрес http://169.254.169.254/latest/meta-data/, и на хосте с IMDSv1 обычный GET на .../iam/security-credentials/<role> возвращает живые, временные IAM-учётки — без аутентификации, потому что API предполагает, что до link-local-адреса дотянется только код на самой машине. SSRF ломает это предположение. Тот же трюк бьёт по внутренним админкам, отладочным портам localhost: и диапазонам RFC1918 (10.x, 172.16–31.x, 192.168.x), которым нечего быть доступными из интернета.
// ❌ Vulnerable image proxy: fetches whatever the caller names
app.get("/proxy", async (req, res) => {
const upstream = req.query.url; // attacker-controlled
const r = await fetch(upstream); // hits 169.254.169.254 just fine
res.set("content-type", r.headers.get("content-type"));
r.body.pipe(res);
});Наивный фикс — «блокировать строку 169.254.169.254 и localhost» — проваливается тремя способами. Атакующий использует http://[::ffff:169.254.169.254], или десятичный IP http://2852039166/, или подконтрольное ему имя хоста, которое резолвится в IP метаданных. Самый глубокий обход — DNS rebinding (переназначение DNS — атака TOCTOU, когда запись меняется между моментом проверки и моментом использования): ты проверяешь evil.com, видишь, что он резолвится в публичный IP, одобряешь — а между твоей проверкой и собственным DNS-запросом fetch запись переключается на 169.254.169.254. Ты провалидировал один IP, а соединился с другим. Фикс: разреши хост сам, провалидируй разрешённый IP против deny-листа внутренних диапазонов, а затем закрепи этот IP для самого соединения, чтобы не было второго, подконтрольного атакующему резолва.
import { lookup } from "node:dns/promises";
import net from "node:net";
const BLOCKED = (ip) =>
ip.startsWith("169.254.") || // link-local / cloud metadata
ip.startsWith("10.") || ip === "127.0.0.1" ||
/^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
async function safeFetch(rawUrl) {
const u = new URL(rawUrl);
if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error("scheme");
if (!ALLOW_HOSTS.has(u.hostname)) throw new Error("host not allow-listed");
const { address } = await lookup(u.hostname); // resolve ONCE, ourselves
if (BLOCKED(address)) throw new Error("blocked target");
// Подключаемся к проверенному IP, шлём Host-заголовок с исходным именем → нет второго lookup'а
return fetch(`${u.protocol}//${address}${u.pathname}`, {
headers: { host: u.hostname },
redirect: "manual", // a 302 → metadata is SSRF too
});
}Важны ещё два контроля. Отключи переход по редиректам (redirect: "manual") — разрешённый хост может отдать 302 прямо на 169.254.169.254, а адрес редиректа повторно не валидируется. И в AWS требуй IMDSv2 с hop limit = 1: IMDSv2 требует PUT для получения токена сессии до любого GET, чего простой SSRF-GET выполнить не может, а hop limit вообще не даёт контейнеризированному прокси дотянуться до него.
▸Почему это работает
DNS rebinding — это баг TOCTOU (time-of-check to time-of-use). Ты резолвишь attacker.com → 93.184.x.x и одобряешь; атакующий держит авторитативный DNS-сервер с TTL = 0 секунд, так что когда fetch делает свой запрос миллисекундами позже, ответ уже 169.254.169.254. Твоя валидация была верной — она просто описывала другое соединение, не то, что случилось. Вот почему сопоставление имени хоста как строки безнадёжно и почему надо разрешить один раз и соединиться с этим самым IP: схлопывание проверки и использования в один резолв полностью закрывает окно.
ReDoS: одна регулярка, замораживающая весь event loop
Видел ли ты когда-нибудь, как Node-сервер замирал на конкретном вводе и приходил в себя через несколько секунд? Прежде чем винить сеть или GC, проверь, нет ли там регулярки с бэктрекингом. Регулярное выражение со вложенными или перекрывающимися квантификаторами может проявить катастрофический бэктрекинг (ReDoS — Regular expression Denial of Service): на определённых несовпадающих входах движок перебирает экспоненциальное число путей, прежде чем сдаться. RegExp в Node — движок с бэктрекингом, и он работает синхронно на единственном потоке event loop — поэтому регулярка, которой нужны две секунды на провал, не замедляет один запрос, а пригвождает CPU к 100% и стопорит каждый параллельный запрос, health-check и таймер на эти две секунды. Это столкновение урока про валидацию ввода с уроком про блокировку event loop: недоверенный ввод гонит неограниченное синхронное вычисление на единственном потоке, который обслуживает всех.
// ❌ "validate a comma-separated list" — looks harmless
const re = /^(\d+,)*\d+$/;
re.test("1,2,3"); // fine
re.test("1".repeat(40)); // 40 chars, no trailing match → catastrophicПаттерн (\d+,)*\d+$ опасен тем, что \d+ внутри * и \d+ после него оба матчат одни и те же цифры — есть экспоненциально много способов разбить серию цифр между ними, и когда финальный $ проваливается, движок перебирает их все. Ввод в ~30–40 символов может загнать такую регулярку в миллиарды шагов и многосекундные зависания; классические нарушители вроде (a+)+$ или (.*,)* и знаменитые уязвимые регулярки email/SAML вызывали реальные сбои. Признак — неоднозначность: две части паттерна, каждая из которых может съесть одни и те же символы.
// ✅ 1) Refactor to a linear, unambiguous pattern
const re = /^\d+(,\d+)*$/; // each comma-group matched exactly once, no overlap
// ✅ 2) Bound the input BEFORE matching — cheap and decisive
if (input.length > 256) throw new Error("too long");
// ✅ 3) For user-driven matching, use a linear-time engine (no backtracking)
import RE2 from "re2";
const safe = new RE2("^(\\d+,)*\\d+$"); // RE2 guarantees O(n), can't blow up
safe.test("1".repeat(100000)); // returns fast, never hangsСеньорский рефлекс слоистый: никогда не запускай регулярку с бэктрекингом по вводу атакующего без жёсткого ограничения длины; переписывай неоднозначные паттерны так, чтобы у каждого символа был ровно один способ быть съеденным; а там, где действительно нужны богатые пользовательские или сложные паттерны, переходи на RE2 — движок без бэктрекинга с гарантиями линейного времени, чтобы вредоносная строка была математически неспособна вызвать экспоненциальное зависание. Если тяжёлого матчинга не избежать, вынеси его с главного цикла в Worker с таймаутом, чтобы зависание убивало воркер, а не сервер.
Path traversal: когда path.join уходит за границы
Эндпойт раздачи файлов берёт имя от пользователя, склеивает его с базовым каталогом и читает результат. Уязвимость в том, что сегменты ../ лезут вверх: ввод ../../../../etc/passwd (или URL-кодированный ..%2f..%2fetc%2fpasswd, или null-байт ..%00.png) выходит из задуманной папки и читает произвольные файлы. Ловушка, в которую попадают все: path.join(base, userInput) не удерживает traversal. join нормализует путь, то есть резолвит сегменты .. и спокойно поднимается выше base.
// ❌ Vulnerable: join normalizes ../ and escapes the directory
app.get("/files/:name", (req, res) => {
const full = path.join("/srv/uploads", req.params.name);
// name = "../../etc/passwd" → full = "/etc/passwd"
res.sendFile(full);
});Удержание — единственный надёжный фикс: разреши путь в абсолютную форму, а затем проверь, что результат всё ещё внутри базового каталога, до обращения к файловой системе. Проверка — это сопоставление префикса с base + path.sep (разделитель защищает от того, чтобы соседний путь вроде /srv/uploads-secret проскользнул мимо голого startsWith("/srv/uploads")).
// ✅ Safe: resolve, then assert containment
const BASE = path.resolve("/srv/uploads");
app.get("/files/:name", (req, res) => {
const full = path.resolve(BASE, req.params.name);
if (full !== BASE && !full.startsWith(BASE + path.sep)) {
return res.status(400).send("invalid path"); // escaped → reject
}
if (req.params.name.includes("\0")) return res.status(400).end(); // null byte
res.sendFile(full);
});Ещё сильнее: вообще не принимай пути. Сопоставь пользовательский непрозрачный id реальному пути через таблицу соответствий ({ "a1b2": "/srv/uploads/report.pdf" }), чтобы имя в файловой системе никогда не выводилось из ввода. Пока укрепляешь поверхность запроса, ограничь размер тела запроса — неограниченное JSON- или upload-тело это DoS на истощение памяти, а express.json({ limit: "100kb" }) (или эквивалент твоего фреймворка) превращает открытую аллокацию в ограниченную. Ввод, управляющий путём, регуляркой или памятью запроса, имеет одну корневую причину: недоверенные данные рулят серверной операцией, которая считала себя доверенной.
Твоему прокси картинок надо загружать URL от пользователя. Какая защита от SSRF реально закрывает дыры эксфильтрации метаданных и DNS rebinding?
Почему один запрос, совпадающий с уязвимой регуляркой вроде /^(\d+,)*\d+$/, может уронить весь Node-сервер, а не только этот запрос?
- 01Почему валидация того, что имя хоста резолвится в публичный IP, и затем вызов fetch(url) всё равно допускает SSRF, и какой правильный вид у фикса?
- 02Почему path.join(base, userInput) не защита от path traversal, и какая проверка реально удерживает путь?
Три серверные уязвимости Node имеют одну корневую причину — недоверенный ввод рулит операцией, которую сервер считал доверенной — и у каждой точное удержание. SSRF превращает любую фичу с URL от пользователя (вебхук, прокси картинок, превью ссылок) в зонд твоей внутренней сети и эндпойта облачных метаданных по адресу http://169.254.169.254/, где GET к IMDSv1 отдаёт живые IAM-учётки; блок-листы строк проваливаются на десятичных/IPv6-формах и особенно на DNS rebinding, поэтому надо разрешить хост один раз самому, отвергнуть link-local/RFC1918/loopback IP, закрепить соединение на этом разрешённом IP, отключить переход по редиректам и требовать IMDSv2 с hop limit. ReDoS эксплуатирует катастрофический бэктрекинг в неоднозначных паттернах вроде (\d+,)*\d+$; поскольку регулярка Node работает синхронно на единственном потоке event loop, подобранная строка в ~30-40 символов может накрутить миллиарды шагов и заморозить каждый параллельный запрос, поэтому ограничивай длину ввода, переписывай паттерны до однозначности и используй линейный движок RE2 для пользовательского матчинга. Path traversal выходит из каталога через ../ (или кодированные/null-байтовые варианты), и path.join не помогает, потому что нормализует .. вверх — вместо этого делай path.resolve и проверяй, что путь всё ещё начинается с base + path.sep, либо сопоставляй непрозрачные id реальным путям. Наконец, ограничь размер тела запроса, чтобы неограниченное JSON или upload не стало DoS на истощение памяти. Теперь, когда увидишь тикет «загружаем URL, который дал юзер» или регулярку с + внутри * — ты знаешь, какую именно катастрофу каждый из них приглашает и что добавить до шипа.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.