Перейти к основному содержанию
Разработка4 мин чтения

Безопасность веб-приложения: чек-лист перед запуском

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

Многослойная геометрическая схема защиты приложения

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

Права доступа: самое частое место ошибок

Правило простое: каждый запрос к данным пользователя ограничивается идентификатором текущего пользователя в самом запросе. Не в интерфейсе, не в проверке перед запросом, а внутри условия выборки. Тогда забыть проверку структурно сложнее.

Ограничение выборки владельцем записи
// Уязвимо: номер документа перебирается вручную
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Ограничивает источники скриптов и запросов

Итоговый чек-лист

  1. Каждый запрос к данным пользователя ограничен его идентификатором внутри условия выборки.
  2. Все входящие данные проверяются на сервере по схеме, суммы пересчитываются из базы.
  3. Запросы к базе параметризованы, склейки строк нет ни в одном месте.
  4. Пароли хешируются алгоритмом для паролей, сессии отзываемы.
  5. Частота попыток входа и отправки форм ограничена.
  6. Секреты в переменных окружения, публичные и приватные ключи разделены.
  7. Заголовки безопасности настроены, политика содержимого проверена в режиме наблюдения.
  8. Файлы, загружаемые пользователями, проверяются по типу и размеру и хранятся вне корня сайта.

Безопасность — не отдельный этап перед запуском, а свойство того, как написан каждый обработчик. Но чек-лист перед публикацией всё равно нужен: он ловит то, что пропустили.

Частые вопросы

Какая уязвимость встречается в бизнес-приложениях чаще всего?
Отсутствие проверки владельца записи: запрос выбирает данные по идентификатору без ограничения текущим пользователем, и любой авторизованный посетитель получает доступ к чужим документам простым перебором номеров. Ограничение должно находиться внутри условия выборки, а не в интерфейсе.
Достаточно ли проверять данные формы в браузере?
Нет. Запрос можно отправить напрямую, минуя интерфейс, поэтому серверная проверка обязательна всегда. Особенно важно никогда не доверять суммам и ценам из формы: их необходимо пересчитывать по данным из базы, а количество товара проверять на целое положительное значение в пределах лимита.
Как защититься от SQL-инъекций?
Единственное надёжное средство — параметризованные запросы, где значение передаётся отдельно от текста запроса. Ручное экранирование строк не заменяет их. Любая склейка запроса из пользовательских данных считается уязвимостью независимо от количества проверок рядом.
Какие заголовки безопасности нужно настроить?
Базовый набор: nosniff против угадывания типа файла, Referrer-Policy против утечки адресов, HSTS для обязательного HTTPS, X-Frame-Options против встраивания и политика безопасности содержимого. Последнюю следует сначала включить в режиме наблюдения, собрать нарушения и только затем переводить в запрещающий режим.
безопасностьчек-листкачество кода

Похожая задача в вашем проекте?

Разберём вашу ситуацию и пришлём оценку по этапам — без обязательств и общих слов.

Обсудить задачу

Вопрос по статье
или по своему проекту

Отвечаем на заявки в течение одного рабочего дня. Если задача не наша — скажем прямо и подскажем, к кому обратиться.

Написать нам

Поля со звёздочкой обязательны для заполнения.