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

За несколько лет разбора чужих проектов мы почти не встречали изощрённых атак. Практически все находки — забытая проверка прав, доверие данным из формы и секреты в коде. Ниже наш рабочий чек-лист, сгруппированный по приоритету.
Права доступа: самое частое место ошибок
Правило простое: каждый запрос к данным пользователя ограничивается идентификатором текущего пользователя в самом запросе. Не в интерфейсе, не в проверке перед запросом, а внутри условия выборки. Тогда забыть проверку структурно сложнее.
// Уязвимо: номер документа перебирается вручную
const doc = await db.documents.findUnique({ where: { id } })
// Верно: условие владельца — часть запроса
const session = await requireSession()
const doc = await db.documents.findFirst({
where: { id, userId: session.user.id },
})
if (!doc) {
// Одинаковый ответ для «нет записи» и «нет прав»:
// иначе по разнице ответов проверяется существование документа
notFound()
}Проверка входящих данных
- Проверка на сервере обязательна независимо от проверки в браузере: запрос отправляется и напрямую, минуя интерфейс.
- Описывайте схему данных и проверяйте по ней — так забыть поле сложнее, чем при россыпи условий.
- Ограничивайте длину строк и размер файлов до обработки, а не после.
- Никогда не доверяйте суммам и ценам, пришедшим из формы. Пересчитывайте по данным из базы.
// Уязвимо: цена приходит от клиента
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0)
// Верно: цены берутся из базы, от клиента только идентификаторы
const ids = items.map((item) => item.productId)
const products = await db.products.findMany({ where: { id: { in: ids } } })
const priceById = new Map(products.map((p) => [p.id, p.price]))
const total = items.reduce((sum, item) => {
const price = priceById.get(item.productId)
if (!price) throw new Error("Товар не найден")
// Количество тоже проверяем: целое, положительное, в пределах лимита
if (!Number.isInteger(item.qty) || item.qty < 1 || item.qty > 100) {
throw new Error("Некорректное количество")
}
return sum + price * item.qty
}, 0)Инъекции
Против SQL-инъекций работает единственное надёжное средство — параметризованные запросы. Никакая экранизация строк руками не заменяет их. Если в коде встречается склейка запроса из пользовательских данных, это уязвимость независимо от того, сколько проверок стоит рядом.
// Уязвимо: значение попадает прямо в текст запроса
const rows = await db.query(
`select * from users where email = '${email}'`
)
// Верно: значение передаётся отдельно от текста запроса
const rows = await db.query(
"select * from users where email = $1",
[email],
)Секреты и переменные окружения
- Ключи и пароли только в переменных окружения, никогда в коде и никогда в репозитории.
- Помните о префиксе публичных переменных: всё, что помечено как доступное браузеру, попадает в бандл и видно любому посетителю.
- Разные ключи для разработки и продакшена. Утечка тестового ключа не должна давать доступ к рабочим данным.
- При подозрении на утечку ключ отзывается, а не «наблюдается».
Заголовки ответа
Несколько заголовков закрывают целые классы атак и настраиваются один раз в конфигурации проекта. Это дешёвая мера, которую почему-то часто пропускают.
| Заголовок | Что даёт |
|---|---|
| X-Content-Type-Options: nosniff | Браузер не угадывает тип файла |
| Referrer-Policy | Ограничивает утечку адресов при переходах |
| Strict-Transport-Security | Только защищённое соединение |
| X-Frame-Options | Защита от встраивания в чужую страницу |
| Content-Security-Policy | Ограничивает источники скриптов и запросов |
Итоговый чек-лист
- Каждый запрос к данным пользователя ограничен его идентификатором внутри условия выборки.
- Все входящие данные проверяются на сервере по схеме, суммы пересчитываются из базы.
- Запросы к базе параметризованы, склейки строк нет ни в одном месте.
- Пароли хешируются алгоритмом для паролей, сессии отзываемы.
- Частота попыток входа и отправки форм ограничена.
- Секреты в переменных окружения, публичные и приватные ключи разделены.
- Заголовки безопасности настроены, политика содержимого проверена в режиме наблюдения.
- Файлы, загружаемые пользователями, проверяются по типу и размеру и хранятся вне корня сайта.
Безопасность — не отдельный этап перед запуском, а свойство того, как написан каждый обработчик. Но чек-лист перед публикацией всё равно нужен: он ловит то, что пропустили.
Частые вопросы
- Какая уязвимость встречается в бизнес-приложениях чаще всего?
- Отсутствие проверки владельца записи: запрос выбирает данные по идентификатору без ограничения текущим пользователем, и любой авторизованный посетитель получает доступ к чужим документам простым перебором номеров. Ограничение должно находиться внутри условия выборки, а не в интерфейсе.
- Достаточно ли проверять данные формы в браузере?
- Нет. Запрос можно отправить напрямую, минуя интерфейс, поэтому серверная проверка обязательна всегда. Особенно важно никогда не доверять суммам и ценам из формы: их необходимо пересчитывать по данным из базы, а количество товара проверять на целое положительное значение в пределах лимита.
- Как защититься от SQL-инъекций?
- Единственное надёжное средство — параметризованные запросы, где значение передаётся отдельно от текста запроса. Ручное экранирование строк не заменяет их. Любая склейка запроса из пользовательских данных считается уязвимостью независимо от количества проверок рядом.
- Какие заголовки безопасности нужно настроить?
- Базовый набор: nosniff против угадывания типа файла, Referrer-Policy против утечки адресов, HSTS для обязательного HTTPS, X-Frame-Options против встраивания и политика безопасности содержимого. Последнюю следует сначала включить в режиме наблюдения, собрать нарушения и только затем переводить в запрещающий режим.
Похожая задача в вашем проекте?
Разберём вашу ситуацию и пришлём оценку по этапам — без обязательств и общих слов.
Обсудить задачу

