Где валидировать: слои и контекст

Одной проверки «на входе» мало: данные опасны по-разному в разных местах.

Контекстная безопасность — идея, что одни и те же данные требуют разной обработки в зависимости от того, куда они попадают: в SQL, в HTML, в имя файла или в shell-команду.

Два разных рубежа

Начинающие сводят всё к «валидации на входе». На деле есть два дополняющих рубежа:

  • Валидация на входе — проверяем, что данные вообще осмысленны (тип, формат, диапазон). Делается как можно раньше, ближе к получению.
  • Безопасный вывод (output encoding) — преобразуем данные под конкретный приёмник перед использованием. Делается как можно позже, у самого «потребителя».

Эти рубежи не взаимозаменяемы. Валидный email a'[email protected] формально корректен, но в SQL его всё равно надо параметризовать, а в HTML — экранировать. Безопасность создаёт именно обработка в точке использования.

Почему так разделено? У двух рубежей разные цели и разное знание о данных. Валидация на входе отвечает на вопрос «осмысленны ли эти данные для нашей предметной области?» — и в этот момент мы ещё не знаем, в скольких разных местах они будут использованы. Безопасный вывод отвечает на вопрос «как безопасно вставить это значение вот в этот конкретный приёмник?» — и тут уже всё известно про контекст, но поздно судить о бизнес-смысле. Попытка слить два рубежа в один всегда что-то теряет: либо вы экранируете слишком рано и не под тот контекст, либо валидируете слишком поздно и пропускаете бессмысленные данные вглубь системы.

Хорошая ментальная модель — «валидируй рано, экранируй поздно». На входе вы отсекаете явный мусор как можно ближе к границе, пока данные ещё «свежие». А обезвреживание под приёмник откладываете до самого последнего момента — туда, где точно известно, HTML это, SQL, заголовок ответа или имя файла. Между этими двумя точками значение живёт как обычные доменные данные, и его не нужно повторно «чистить» на каждом шаге.

Один и тот же ввод — разные опасности

КонтекстОпасный символЗащита
SQL-запрос'параметризация / ORM
HTML-страница< >HTML-экранирование
shell-команда; | &массив аргументов, без shell
имя файла / путь.. /каноникализация + проверка базы

Вывод: нельзя «один раз почистить» данные и считать их безопасными навсегда. Строка, безопасная для HTML, может быть опасна в SQL.

Эта таблица — хорошая иллюстрация того, почему «безопасность» не является свойством самой строки. Один и тот же символ-апостроф безвреден в HTML-тексте, но способен сломать SQL-запрос; точка с запятой ничего не значит в имени, но превращается в разделитель команд в shell. Каждый приёмник имеет собственный набор «магических» символов и собственный способ их обезвредить. Значит, и обработка должна выбираться под приёмник: HTML-экранирование для разметки, параметризация для запроса, массив аргументов для процесса, нормализация и проверка базового каталога для пути. Универсального «обеззараживателя на все случаи» не существует — и попытка его построить как раз и приводит к дырам.

Валидируйте на каждой границе доверия

В системе из нескольких сервисов каждый сервис валидирует свой ввод сам, даже если данные пришли от «своего» соседа. Сосед мог быть скомпрометирован или сам передать чужой пользовательский ввод. Это прямое применение defense in depth.

Соблазн «провалидировать один раз на периметре и дальше всем доверять» понятен, но опасен. Во-первых, периметр со временем дырявеет: появляется новая точка входа — фоновая задача, импорт из файла, вызов из соседнего сервиса в обход шлюза — и она минует ту единственную проверку. Во-вторых, в распределённой системе нет «одного входа»: внутренние очереди, межсервисные вызовы и кеши — это полноценные границы, через которые данные могут прийти неожиданно. Сервис, который верит, что «выше уже проверили», превращает любой сбой соседа в собственную уязвимость. Поэтому каждый компонент отвечает за свою корректность сам; дублирование проверок здесь — не избыточность, а слой защиты.

// Уязвимо: внутренний сервис доверяет, что данные уже проверены «выше»
function processOrder(order) {
  db.save(order); // а вдруг amount отрицательный или quantity = -5?
}

// Безопасно: каждый сервис валидирует свой ввод независимо
function processOrder(order) {
  assertPositiveInt(order.quantity);
  assertMoney(order.amount);
  db.save(order);
}

Как работает под капотом: схемы и типы как валидация

Удобно описывать допустимый ввод декларативно — схемой (JSON Schema, валидаторы DTO, типы с проверками). Схема — это формализованный allowlist: она задаёт типы, диапазоны и обязательность полей в одном месте, отвергает лишние поля и снижает шанс забыть проверку. Сильная типизация на границе превращает «строку откуда-то» в проверенный объект, дальше по коду уже доверенный.

У декларативной схемы есть ещё одно ценное свойство — она делает валидацию видимой и проверяемой. Когда правила разбросаны по коду в виде россыпи if-ов, легко не заметить пробел: одно поле проверено в трёх местах, другое — нигде. Схема собирает контракт ввода в одном месте, где его можно прочитать, отревьюить и протестировать целиком. Особенно полезно требование «отвергать неизвестные поля» (strict-режим): оно превращает схему из «проверяю то, что ожидаю» в полноценный allowlist «принимаю только то, что описано», закрывая трюки вроде подмешивания лишних параметров в JSON-тело.

Наконец, типизированная граница даёт выигрыш и в надёжности, и в читаемости. Когда «сырой» ввод однажды превращён в строго типизированный доменный объект, остальной код работает уже с гарантиями: число точно число, статус точно из перечисления. Это снимает с разработчика необходимость на каждом шаге гадать, проверены ли данные, и переносит ответственность за корректность на узкую, хорошо протестированную границу.

Частые ошибки

  • «Провалидировали — значит, безопасно везде». Нет: безопасность даёт обработка под конкретный контекст.
  • Экранировать на входе. Раннее экранирование портит данные и не учитывает будущий контекст; экранируйте на выходе.
  • Доверять соседнему сервису. Каждая граница доверия требует своей проверки.
  • Принимать «лишние» поля молча. Без strict-режима в схему просачиваются неожиданные параметры; отвергайте всё, что не описано.

Итоги

  • Валидация на входе и безопасный вывод — два разных, дополняющих рубежа.
  • Опасность данных зависит от контекста: SQL, HTML, shell, путь требуют разной защиты.
  • Экранируйте у точки использования, валидируйте на каждой границе доверия.
  • Схемы и типы — это формализованный allowlist в одном месте.
Проверьте себя
1. Почему валидации на входе недостаточно для безопасности?
AОна слишком медленная
BОпасность данных зависит от контекста использования (SQL, HTML, shell), и защиту даёт обработка в точке использования
CВалидация на входе вообще не нужна
DПотому что вход всегда доверенный
2. Где правильнее делать экранирование вывода?
AКак можно раньше, сразу на входе
BКак можно позже, у точки использования, под конкретный контекст (HTML, SQL, shell)
CТолько в базе данных
DЭкранирование вообще не нужно при валидации
3. Зачем валидировать ввод в каждом сервисе, даже если он пришёл от соседнего внутреннего сервиса?
AЧтобы замедлить систему
BСосед мог быть скомпрометирован или передавать чужой пользовательский ввод; это defense in depth
CВнутренние сервисы не отправляют данные
DЭто требование к именованию