Чек-лист безопасности веб-приложения перед релизом
Большинство инцидентов безопасности в вебе не про сложные атаки, а про базовые вещи, которые забыли проверить перед релизом. Собрали чек-лист, который закрывает основную часть рисков.

Чек-лист ниже не привязан к конкретному фреймворку или языку: принципы параметризованных запросов, экранирования вывода и правильных заголовков одинаково применимы к PHP-проекту на Laravel, к Node.js-сервису на Express и к любому другому стеку, потому что они описывают поведение браузера и базы данных, а не особенности конкретной библиотеки.
Опросы и отчёты по инцидентам из года в год показывают одну и ту же картину без сюрпризов: подавляющее большинство взломов веб-приложений не связаны с хитрыми атаками нулевого дня, а эксплуатируют базовые упущения, которые можно было найти и закрыть ещё до релиза. SQL-инъекция через неэкранированный параметр, секретный ключ, случайно попавший в публичный репозиторий, устаревшая библиотека с известной уязвимостью: всё это находится обычным чек-листом, без специализированных инструментов анализа безопасности.
Инъекции: SQL и не только SQL
SQL-инъекция остаётся актуальной угрозой не потому, что про неё не знают, а потому что в большом проекте с десятками точек входа легко пропустить одно место, где строка запроса собирается конкатенацией вместо параметризованного запроса. Проверка одна и простая: любое значение, пришедшее от пользователя, должно попадать в запрос только через параметры, никогда через прямую вставку в строку.
// опасно: значение из запроса подставлено прямо в строку SQL
$result = $pdo->query("SELECT * FROM users WHERE email = '{$email}'");
// безопасно: значение передано как параметр, а не как часть текста запроса
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);Та же логика угрозы касается не только SQL: команды операционной системы, собранные конкатенацией с пользовательским вводом, запросы к LDAP, да и построение путей к файлам на основе ввода пользователя, все эти случаи решаются одним принципом: пользовательские данные это данные, а не код, и смешивать их с исполняемыми конструкциями напрямую нельзя.
Отдельно стоит проверить код, который формирует имя файла или путь на диске из пользовательского ввода, например при загрузке аватара или документа. Без проверки значение вроде ../../etc/passwd в имени файла способно вывести операцию за пределы предполагаемой папки, и эта категория уязвимостей называется traversal по пути. Простая защита: приводить имя файла к безопасному виду самостоятельно, не доверяя оригинальному имени от пользователя, и хранить файлы по собственному сгенерированному идентификатору, а не по исходному имени.
XSS: где пользовательский текст становится html
Межсайтовый скриптинг (XSS) возникает там, где текст, введённый пользователем, выводится на страницу без экранирования и браузер интерпретирует его как html или javascript. Современные шаблонизаторы экранируют вывод по умолчанию, и это снижает риск, но не убирает его полностью: разработчик может явно отключить экранирование для конкретного блока, если нужно вывести html, и именно это место требует отдельной проверки.
- Весь пользовательский текст в шаблонах экранируется по умолчанию, отключение экранирования это осознанное редкое исключение
- Если нужно разрешить ограниченный html от пользователя, например в комментариях, используется библиотека санитайзера с явным списком разрешённых тегов
- Данные, которые передаются в javascript как переменная, кодируются как json, а не вставляются в виде строки внутрь тега script
CSRF и заголовки безопасности
CSRF-атака заставляет браузер пользователя, уже авторизованного на сайте, отправить запрос от его имени без его согласия, обычно через скрытую форму на стороннем сайте. Защита строится на CSRF-токене, уникальном для сессии значении, которое форма отправляет вместе с данными, и сервер проверяет его перед выполнением изменяющей операции. Для API, которые не используют куки для авторизации, а токен в заголовке, риск CSRF ниже по своей природе, но заголовки всё равно нужно настраивать явно.
| Заголовок | Зачем нужен |
|---|---|
| Content-Security-Policy | ограничивает, откуда странице разрешено загружать скрипты и ресурсы |
| X-Content-Type-Options | запрещает браузеру угадывать тип содержимого файла |
| Strict-Transport-Security | заставляет браузер всегда использовать https для домена |
| X-Frame-Options | запрещает встраивать страницу во фрейм на чужом сайте |
Секреты в коде: находить до того, как найдёт сканер снаружи
Ключ от внешнего API, пароль от базы данных или приватный сертификат, попавший в публичный или даже приватный, но слишком широко доступный репозиторий, это один из самых частых и самых дорогих способов компрометации. Проблема не только в том, что секрет виден в текущем коде, а в том, что он остаётся в истории git навсегда, даже если удалить его из последнего коммита.
- Все секреты хранятся в переменных окружения или менеджере секретов, никогда прямо в коде или конфигурационном файле репозитория
- Перед релизом полезно прогнать автоматический сканер секретов по истории коммитов, а не только по текущему состоянию
- Если секрет всё же попал в историю, считать его скомпрометированным и обязательно его ротировать, а не только удалять упоминание из кода
Зависимости: чужой код в вашем проекте
Среднее веб-приложение собирает подавляющую часть своего кода не из того, что написала команда, а из сторонних библиотек, и уязвимость в любой из них становится уязвимостью всего приложения. Перед релизом стоит прогнать проверку известных уязвимостей по установленным зависимостям: для PHP это composer audit, для JavaScript, npm audit, и оба инструмента сравнивают установленные версии с базой известных уязвимостей.
composer audit
npm audit --productionПолезная привычка: не дожидаться релиза, чтобы узнать об уязвимостях, а подключить автоматическую проверку зависимостей в конвейер сборки, чтобы новый отчёт появлялся при каждом изменении списка пакетов, а не раз в несколько месяцев перед крупным релизом. Чем раньше команда узнаёт об уязвимой версии библиотеки, тем дешевле её заменить, пока вокруг неё не выросло много нового кода, завязанного на её конкретное поведение.
Найденная уязвимость не всегда требует немедленного обновления: нужно оценить, доступен ли уязвимый код пользователю приложения напрямую, или он используется только во внутреннем инструменте сборки, недоступном снаружи. Но полностью игнорировать отчёт аудита, особенно по пакетам с высоким уровнем критичности, рискованно даже если прямого пути эксплуатации пока не видно.
Авторизация: проверка прав, а не только наличия сессии
Отдельная категория ошибок, которую чек-лист по инъекциям и заголовкам не ловит: отсутствие проверки прав доступа к конкретному объекту. Пользователь авторизован, запрос проходит проверку сессии, но эндпоинт отдаёт данные чужого заказа просто по его числовому идентификатору в адресе, без проверки, что этот заказ принадлежит именно этому пользователю. Такая уязвимость иногда называется небезопасной прямой ссылкой на объект, и находится она не сканером, а ручной проверкой каждого эндпоинта, который принимает идентификатор чужой сущности.
- Каждый эндпоинт, читающий или меняющий конкретную запись по идентификатору, должен явно проверять, что запись принадлежит текущему пользователю или доступна ему по роли
- Проверка прав выполняется на сервере при каждом запросе, а не только один раз при отображении интерфейса, который просто скрывает кнопку
- Для административных эндпоинтов отдельно стоит проверить, что обычный пользователь не может вызвать их напрямую, зная или подобрав адрес
Чек-лист перед релизом не заменяет полноценный аудит безопасности для критичных систем, но закрывает основную массу реальных инцидентов: параметризованные запросы вместо конкатенации, экранирование вывода по умолчанию, токены против CSRF и правильные заголовки, отсутствие секретов в репозитории и регулярная проверка зависимостей. Эти пункты недороги в проверке и дорого стоят, если их пропустить.
Частые вопросы
Достаточно ли автоматического сканера, чтобы не проверять чек-лист руками?
Сканеры хорошо находят известные паттерны, такие как явную конкатенацию в SQL-запросе или утечку секрета, но не заменяют понимание логики конкретного приложения, особенно в местах с нестандартной авторизацией.
Нужен ли CSRF-токен для публичного API без куки-авторизации?
Риск CSRF ниже, если авторизация идёт через токен в заголовке, а не через куки, потому что браузер не прикладывает такой заголовок автоматически к чужим формам. Но заголовки безопасности и проверка источника запроса всё равно стоит настроить.
Что делать, если уязвимость найдена в зависимости, у которой давно нет обновлений?
Оценить реальную доступность уязвимого кода из внешнего запроса, рассмотреть замену библиотеки на поддерживаемую альтернативу, либо временно обернуть уязвимое место дополнительной проверкой на своей стороне до миграции.



