Allowlist против denylist: правильная валидация
Описать «что разрешено» надёжнее, чем перечислить «что запрещено».
Allowlist (белый список) — валидация, пропускающая только заранее заданное допустимое. Denylist (чёрный список) — попытка перечислить и заблокировать всё плохое.
Почему denylist почти всегда проигрывает
Чёрный список требует предусмотреть все варианты атаки — а атакующему достаточно найти один, который вы упустили. Кодировки, регистр, юникод-омоглифы, двойное экранирование — обходов слишком много. Allowlist переворачивает задачу: вы описываете узкое множество допустимого, и всё остальное отвергается автоматически, включая то, о чём вы не подумали.
Разница между двумя подходами — это разница между закрытой и открытой по умолчанию системой. Denylist по умолчанию разрешает всё и пытается вычесть плохое: любой пробел в списке запретов оборачивается дырой. Allowlist по умолчанию запрещает всё и добавляет только заведомо хорошее: пробел в списке разрешённого приводит максимум к ложному отказу легитимному пользователю — неприятно, но не опасно. С точки зрения безопасности это асимметрия в нашу пользу: ошибка allowlist стоит жалобы в поддержку, ошибка denylist стоит взлома.
Есть и эксплуатационная выгода. Со временем мир атак расширяется: появляются новые спецсимволы, новые кодировки, новые приёмы. Denylist стареет — его приходится постоянно догонять, дописывая запреты под каждую свежую технику. Allowlist стареет гораздо медленнее: множество допустимого для вашего поля (скажем, «буквы и дефис в имени») не меняется от того, что атакующие придумали новый трюк. Вы описали форму нужного один раз, и она остаётся верной.
// Уязвимо (denylist): пытаемся вырезать «плохое»
if (name.contains("<script>")) reject(); // а <ScRiPt>? <img onerror>? обход тривиален
// Безопасно (allowlist): описываем допустимый формат
if (!name.matches("^[\p{L} '-]{1,50}$")) reject(); // только буквы, пробел, апостроф, дефис
Что описывать в allowlist
Хорошая валидация проверяет несколько измерений данных:
| Измерение | Пример проверки |
| тип | это целое число, а не строка |
| диапазон / длина | возраст 0..150; имя ≤ 50 символов |
| формат | email по строгому шаблону; дата ISO-8601 |
| множество значений | статус ∈ {new, paid, shipped} |
Для перечислимых значений allowlist особенно силён: статус заказа — это одно из четырёх слов, и любая «креативность» во вводе отвергается.
Эти измерения стоит проверять вместе, а не по отдельности, потому что атака часто прячется в «зазоре» между ними. Скажем, поле формально проходит проверку типа (это строка) и формата (символы допустимые), но имеет длину в сотни тысяч символов — и становится вектором отказа в обслуживании или переполнения дальше по коду. Или значение попадает в диапазон, но нарушает бизнес-правило: дата рождения «в будущем» синтаксически корректна, а семантически абсурдна. Хорошая валидация — это слоёная воронка: тип отсекает грубо неверное, длина и диапазон — выходящее за разумные рамки, формат — структурно ломаное, множество значений — всё, что не входит в заранее известный набор.
Отдельно отметим длину как недооценённое измерение. Ограничение длины почти всегда уместно и почти всегда забывается. Оно дёшево, не зависит от языка и закрывает целый класс проблем — от раздувания памяти до обхода нижележащих проверок очень длинной строкой. Если у поля нет естественного потолка длины, это уже повод задуматься, действительно ли вы понимаете, что туда может прийти.
Валидация против санитизации
Это разные операции, и их путают.
- Валидация — принять или отвергнуть данные целиком. «Это валидный email? Нет → ошибка».
- Санитизация — преобразовать данные в безопасный вид. «Обрезать пробелы», «удалить опасные теги».
Предпочитайте валидацию: отвергнуть подозрительное честнее, чем «починить» и пропустить. Санитизация уместна, когда вы обязаны принять свободный текст (комментарий), но и тогда ключевую безопасность даёт контекстное экранирование на выводе, а не «очистка» на входе.
Коварство санитизации в том, что она пытается угадать намерение пользователя и почти всегда угадывает неверно. Удалили <script> из строки — но вложенная конструкция <scr<script>ipt> после удаления внутреннего вхождения снова собралась в опасный тег. «Починили» один раз — открыли дорогу для подбора такого ввода, который после «починки» превращается ровно в то, что фильтр должен был вырезать. Валидация этой ловушки лишена: она ничего не достраивает, а просто говорит «да» или «нет». Если же свободный текст принять всё-таки нужно, разумнее хранить его как есть и обезвреживать строго на выводе, под конкретный приёмник, — тогда исходные данные не искажаются, а безопасность обеспечивается там, где известен контекст.
Как работает под капотом: каноникализация перед проверкой
Прежде чем сравнивать ввод с allowlist, приведите его к канонической форме: декодируйте URL-кодировку, нормализуйте юникод (NFC), приведите регистр там, где он не важен. Иначе %2e%2e%2f и ../ для вашей проверки — разные строки, и обход проходит. Порядок важен: сначала каноникализация, потом валидация.
Причина в том, что один и тот же логический символ может быть записан множеством способов. Точка — это ., но также %2e в URL-кодировке и даже %252e при двойном кодировании; буква может существовать в нескольких юникод-формах, выглядящих одинаково, но различных байтово. Если ваша проверка сравнивает «сырые» байты, а нижележащий слой (веб-сервер, файловая система, БД) сначала декодирует строку и лишь потом ею пользуется, то проверка и реальное использование смотрят на разные значения. Атака живёт именно в этом расхождении.
Из этого следует практическое правило: каноникализацию надо делать ровно один раз и в одном месте — на входе, до всех проверок, — а дальше передавать уже нормализованное значение. Повторная или, наоборот, пропущенная нормализация на разных слоях создаёт тот самый разрыв между «что проверили» и «что выполнили».
Частые ошибки
- Регулярка-denylist «на коленке». Всегда найдётся обход; описывайте допустимое.
- Валидация после декодирования в другом слое. Проверили, потом что-то декодировало строку — проверка устарела.
- Считать санитизацию заменой экранированию. Безопасность вывода даёт контекстное экранирование, а не входная «чистка».
- Забыть про ограничение длины. Допустимый формат без верхней границы длины открывает путь к отказу в обслуживании и обходу проверок.
- Чинить вместо отказа. «Молчаливое» удаление подозрительных символов из ввода маскирует атаку и порождает неожиданные значения; честнее отвергнуть и сообщить об ошибке.
Итоги
- Allowlist описывает допустимое и по умолчанию отвергает всё прочее — это надёжнее denylist.
- Проверяйте тип, длину/диапазон, формат и допустимое множество значений.
- Валидация отвергает, санитизация преобразует; предпочитайте отвергать.
- Каноникализируйте ввод до проверки.