Как делать11 октября 2026 г.11 мин чтения
DevOps, тестирование и безопасностьШаг 11 из 12

OWASP Top 10 для разработчика

Коротко

OWASP Top 10 — документ OWASP о десяти самых критичных категориях рисков веб-приложений; актуальная опубликованная редакция — 2025 года. В ней первое место по-прежнему у нарушения контроля доступа, на второе поднялась небезопасная конфигурация, появились категории про цепочку поставок ПО и обработку исключительных ситуаций. Каждая категория — это группа слабостей (CWE), которые закрываются конкретными приёмами в коде: проверкой владельца записи на сервере, параметризованными запросами, экранированием вывода, медленным хешированием паролей и отказом «по умолчанию».

ОсновыУровень: СреднийБезопасность приложений

После материала вы сможете

  • назвать категории OWASP Top 10:2025 и объяснить, чем редакция 2025 отличается от 2021
  • для каждой категории привести пример уязвимого кода на Node.js, Express или SQL и его исправление
  • воспроизвести SQL-инъекцию и XSS на локальном стенде и доказать тестом, что параметризация и экранирование их закрывают
  • проверить свой проект по чек-листу и выбрать инструменты проверки: тесты, npm audit, DAST, ASVS

Проверено на

OWASP Top 10:2025Node.js 24 LTS (24.18)Express 5.3pg 8.23PostgreSQL 17npm 11.16

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что такое OWASP Top 10 и как читать редакцию 2025
  2. 02A01: нарушение контроля доступа
  3. 03A05: инъекции — SQL и XSS
  4. 04A04 и A07: криптография, пароли и вход
  5. 05A02, A03 и A08: конфигурация, зависимости и целостность
  6. 06A06, A09 и A10: дизайн, журналы и исключительные ситуации
  7. 07Как проверить проект: инструменты и чек-лист

Что такое OWASP Top 10 и как читать редакцию 2025

OWASP (Open Worldwide Application Security Project) — некоммерческое сообщество, которое публикует открытые материалы по безопасности приложений. Top 10 сами авторы называют стандартным документом для повышения осведомлённости разработчиков: это консенсус о самых критичных рисках, а не исчерпывающий список и не сертификация. Адрес owasp.org/Top10 ведёт на редакцию 2025 года, страница проекта называет её текущей выпущенной версией. Это восьмой выпуск списка.

Каждая категория — группа CWE (Common Weakness Enumeration, каталог типов слабостей): в 10 категориях редакции 2025 — 248 CWE. Восемь категорий выбраны по данным тестирования более 2,8 млн приложений, две — по опросу практиков, потому что данные отражают прошлое и не всё можно найти автоматически. Авторы старались называть категории по первопричине, а не по симптому.

A01 Broken Access Control — нарушение контроля доступа

Суть
Пользователь делает то, на что у него нет прав
Что изменилось
Остаётся первой; в неё влит SSRF (в 2021 — отдельная A10)

A02 Security Misconfiguration — небезопасная конфигурация

Суть
Опасные настройки по умолчанию, лишние функции, подробные ошибки
Что изменилось
Поднялась с 5-го места

A03 Software Supply Chain Failures — сбои цепочки поставок

Суть
Уязвимые и вредоносные зависимости, сборка, CI/CD
Что изменилось
Расширение A06:2021 «Уязвимые и устаревшие компоненты»

A04 Cryptographic Failures — криптографические ошибки

Суть
Слабые алгоритмы, ключи в коде, пароли без медленного хеша
Что изменилось
Опустилась со 2-го места

A05 Injection — инъекции

Суть
Ввод выполняется как код: SQL, команды ОС, XSS
Что изменилось
Опустилась с 3-го места

A06 Insecure Design — небезопасный дизайн

Суть
Защита не была спроектирована вовсе
Что изменилось
Опустилась с 4-го места

A07 Authentication Failures — ошибки аутентификации

Суть
Подбор паролей, слабые сессии, перечисление аккаунтов
Что изменилось
То же место, короче название

A08 Software or Data Integrity Failures — нарушение целостности

Суть
Непроверенный код и данные считаются доверенными
Что изменилось
Без изменений

A09 Security Logging and Alerting Failures — журналирование и оповещения

Суть
Атаку не видно в логах, а если видно — никто не узнаёт
Что изменилось
В названии «monitoring» заменён на «alerting»

A10 Mishandling of Exceptional Conditions — обработка исключительных ситуаций

Суть
Ошибка приводит к «открытому» состоянию или утечке
Что изменилось
Новая категория
OWASP Top 10:2025 и изменения относительно 2021

Ниже по каждой категории — суть, пара «уязвимый код → исправление» на Node.js 24, Express 5 и PostgreSQL 17 и способ проверки. Уязвимые и исправленные функции лежат рядом, а тесты доказывают разницу. Стенд поднимается в Docker:

bash
docker run -d --rm --name owasp-lab -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:17-alpine
docker exec owasp-lab psql -U postgres -c "CREATE DATABASE owasp_demo"
docker exec -i owasp-lab psql -U postgres -d owasp_demo < db/schema.sql

npm init -y && npm pkg set type=module
npm install express@5 pg@8
npm pkg set scripts.test="node --test --test-concurrency=1"
npm test

docker stop owasp-lab
Изолированный стенд: PostgreSQL 17 в контейнере на порту 55432 и проект с тестами на node:test
db/schema.sql
CREATE TABLE users (
  id            integer PRIMARY KEY,
  email         text NOT NULL UNIQUE,
  role          text NOT NULL CHECK (role IN ('manager', 'admin')),
  password_hash text NOT NULL
);

CREATE TABLE leads (
  id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  owner_id   integer NOT NULL REFERENCES users (id),
  name       text NOT NULL,
  phone      text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO users VALUES
  (1, 'aigerim@example.com', 'manager', 'scrypt$demo-hash-1'),
  (2, 'daniyar@example.com', 'manager', 'scrypt$demo-hash-2'),
  (3, 'admin@example.com',   'admin',   'scrypt$demo-hash-3');

INSERT INTO leads (owner_id, name, phone) VALUES
  (1, 'Coffee shop on Abay', '+77010000001'),
  (1, 'Dental clinic',       '+77010000002'),
  (2, 'Beauty salon',        '+77010000003');
Схема и тестовые данные: два менеджера, администратор, три заявки
support/http-db.js
import pg from "pg";

export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL ?? "postgres://postgres:test@localhost:55432/owasp_demo",
});

export async function serve(app) {
  const server = await new Promise((resolve) => {
    const s = app.listen(0, () => resolve(s));
  });
  const base = `http://localhost:${server.address().port}`;
  return { base, close: () => new Promise((resolve) => server.close(resolve)) };
}
Вспомогательный модуль тестов: пул подключений и запуск Express на свободном порту

A01: нарушение контроля доступа

Контроль доступа следит, чтобы пользователь не выходил за пределы своих прав. Типичные провалы по OWASP: доступ к чужой записи по её идентификатору (IDOR — insecure direct object reference), открытые для всех POST, PUT и DELETE в API, проверки только на фронтенде, подмена JWT или cookie, неверный CORS, «угадывание» адресов админки. Главное правило из рекомендаций: проверка работает, только если она выполняется на сервере, и всё, кроме публичного, по умолчанию запрещено.

src/a01-access.js
// TRAINING EXAMPLE, VULNERABLE: any signed-in user can read any lead by id (IDOR).
export async function getLeadVulnerable(pool, user, id) {
  const { rows } = await pool.query("SELECT id, owner_id, name, phone FROM leads WHERE id = $1", [id]);
  return rows[0] ?? null;
}

// Fixed: ownership is part of the query; admins see everything.
export async function getLead(pool, user, id) {
  const { rows } = await pool.query(
    "SELECT id, owner_id, name, phone FROM leads WHERE id = $1 AND (owner_id = $2 OR $3 = 'admin')",
    [id, user.id, user.role],
  );
  return rows[0] ?? null;
}

// SSRF is part of A01 since 2025: the server fetches only allow-listed hosts.
const ALLOWED_HOSTS = new Set(["api.partner.example"]);

export function assertAllowedUrl(input) {
  const url = new URL(input);
  if (url.protocol !== "https:" || !ALLOWED_HOSTS.has(url.hostname)) {
    throw new Error(`URL not allowed: ${url.origin}`);
  }
  return url;
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно. Исправление переносит проверку владельца в сам SQL-запрос

Почему условие в запросе, а не проверка после: так невозможно «забыть» сравнить owner_id в одном из маршрутов, а чужая запись неотличима от несуществующей — сервер отвечает 404 и не подтверждает, что такой идентификатор есть. Подробно роли, права и выбор между 403 и 404 разобраны в уроке об авторизации.

С 2025 года в A01 входит и SSRF (server-side request forgery): сервер по просьбе пользователя ходит по переданному адресу и попадает во внутреннюю сеть — например, на адрес метаданных облака 169.254.169.254. Шпаргалка OWASP по SSRF рекомендует, когда это возможно, список разрешённых адресатов; функция assertAllowedUrl выше делает именно это и сравнивает имя хоста целиком, а не по подстроке.

test/a01-access.test.js
import assert from "node:assert/strict";
import { after, test } from "node:test";
import { assertAllowedUrl, getLead, getLeadVulnerable } from "../src/a01-access.js";
import { pool } from "../support/http-db.js";

after(() => pool.end());

const aigerim = { id: 1, role: "manager" };
const admin = { id: 3, role: "admin" };
const daniyarLeadId = 3;

test("IDOR: the vulnerable version returns someone else's lead", async () => {
  const lead = await getLeadVulnerable(pool, aigerim, daniyarLeadId);
  assert.equal(lead.name, "Beauty salon");
});

test("the fixed version hides other owners' leads but not from admins", async () => {
  assert.equal(await getLead(pool, aigerim, daniyarLeadId), null);
  assert.equal((await getLead(pool, aigerim, 1)).name, "Coffee shop on Abay");
  assert.equal((await getLead(pool, admin, daniyarLeadId)).name, "Beauty salon");
});

test("SSRF guard accepts only allow-listed https hosts", () => {
  assert.equal(assertAllowedUrl("https://api.partner.example/v1/rates").hostname, "api.partner.example");
  for (const bad of [
    "http://api.partner.example/v1",
    "https://169.254.169.254/latest/meta-data/",
    "https://api.partner.example.evil.test/",
    "file:///etc/passwd",
  ]) {
    assert.throws(() => assertAllowedUrl(bad), /not allowed/);
  }
});
Тесты A01: менеджер Айгерим не видит заявку Данияра, администратор видит; SSRF-фильтр отклоняет http, IP метаданных, похожий домен и file://

Как проверить: на каждый маршрут с идентификатором — тест «второй пользователь получает 404/403». OWASP прямо советует включать функциональные проверки доступа в unit- и интеграционные тесты, а отказы доступа — логировать.

A05: инъекции — SQL и XSS

Инъекция возникает, когда пользовательские данные попадают в интерпретатор — базу данных, командную оболочку, браузер — и часть данных выполняется как команда. Защита по OWASP одна по сути: держать данные отдельно от команд. Начнём с SQL. Уязвимая функция собирает запрос шаблонной строкой:

src/a05-injection.js
// TRAINING EXAMPLE, VULNERABLE: user input is concatenated into SQL text.
export async function searchLeadsVulnerable(pool, ownerId, q) {
  const sql = `SELECT id, name, phone FROM leads WHERE owner_id = ${ownerId} AND name ILIKE '%${q}%'`;
  const { rows } = await pool.query(sql);
  return rows;
}

// Fixed: the query text is constant, values travel separately as parameters.
export async function searchLeads(pool, ownerId, q) {
  const { rows } = await pool.query(
    "SELECT id, name, phone FROM leads WHERE owner_id = $1 AND name ILIKE '%' || $2 || '%'",
    [ownerId, q],
  );
  return rows;
}

// Column names cannot be parameters: pick them from an allow-list.
const SORTABLE = new Map([
  ["name", "name"],
  ["newest", "created_at DESC"],
]);

export async function listLeads(pool, ownerId, sort) {
  const orderBy = SORTABLE.get(sort) ?? "created_at DESC";
  const { rows } = await pool.query(`SELECT id, name FROM leads WHERE owner_id = $1 ORDER BY ${orderBy}`, [
    ownerId,
  ]);
  return rows;
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно. В исправлении текст запроса постоянный, значения идут параметрами $1 и $2
test/a05-injection.test.js
import assert from "node:assert/strict";
import { after, test } from "node:test";
import { listLeads, searchLeads, searchLeadsVulnerable } from "../src/a05-injection.js";
import { pool } from "../support/http-db.js";

after(() => pool.end());

const OR_TRUE = "' OR 1=1 --";
const UNION = "' UNION SELECT id, email, password_hash FROM users --";

test("vulnerable search: OR 1=1 returns other managers' leads", async () => {
  const rows = await searchLeadsVulnerable(pool, 1, OR_TRUE);
  console.log("OR 1=1  →", rows.map((r) => r.name));
  assert.equal(rows.length, 3);
});

test("vulnerable search: UNION leaks the users table", async () => {
  const rows = await searchLeadsVulnerable(pool, 1, UNION);
  console.log("UNION   →", rows.map((r) => `${r.name} ${r.phone}`));
  assert.ok(rows.some((r) => r.name === "admin@example.com"));
});

test("parameterized search treats the payload as plain text", async () => {
  assert.deepEqual(await searchLeads(pool, 1, OR_TRUE), []);
  assert.deepEqual(await searchLeads(pool, 1, UNION), []);
  const rows = await searchLeads(pool, 1, "coffee");
  assert.deepEqual(rows.map((r) => r.name), ["Coffee shop on Abay"]);
});

test("sort column comes from an allow-list", async () => {
  const rows = await listLeads(pool, 1, "name; DROP TABLE leads --");
  assert.equal(rows.length, 2);
  const byName = await listLeads(pool, 1, "name");
  assert.deepEqual(byName.map((r) => r.name), ["Coffee shop on Abay", "Dental clinic"]);
});
Тесты: две атаки на уязвимую функцию и те же строки против исправленной
terminal
$ npm test
…
OR 1=1  → [ 'Coffee shop on Abay', 'Dental clinic', 'Beauty salon' ]
✔ vulnerable search: OR 1=1 returns other managers' leads (29.0166ms)
UNION   → [
  'daniyar@example.com scrypt$demo-hash-2',
  'Dental clinic +77010000002',
  'Coffee shop on Abay +77010000001',
  'aigerim@example.com scrypt$demo-hash-1',
  'admin@example.com scrypt$demo-hash-3'
]
✔ vulnerable search: UNION leaks the users table (3.6767ms)
✔ parameterized search treats the payload as plain text (4.9012ms)
✔ sort column comes from an allow-list (3.4683ms)
Реальный вывод на PostgreSQL 17: OR 1=1 отдал чужую заявку, UNION — e-mail и хеши паролей из другой таблицы

Строка ' OR 1=1 -- закрывает кавычку, добавляет всегда истинное условие и комментирует хвост запроса — поэтому менеджер №1 увидел заявку менеджера №2. UNION SELECT идёт дальше и приклеивает к результату строки из таблицы users. В параметризованной версии библиотека pg отправляет текст запроса и значения отдельно, а PostgreSQL подставляет значения сам — атака превращается в обычный поиск по странной строке и находит ноль заявок.

  • Имена таблиц и колонок параметром передать нельзя: это отмечают и OWASP, и документация node-postgres. Поэтому сортировка выбирается из списка разрешённых значений (SORTABLE), а всё остальное заменяется значением по умолчанию.
  • ORM и конструкторы запросов параметризуют сами, пока вы не используете «сырые» методы со склейкой строк.
  • Для команд ОС то же правило: не собирать строку для shell, а передавать аргументы массивом через execFile — по документации Node.js он не запускает оболочку по умолчанию.

XSS (cross-site scripting) — та же инъекция, только интерпретатор — браузер жертвы. Сервер вставляет ввод в HTML как есть, и строка <script> из адреса выполняется на странице:

src/a05-xss.js
// TRAINING EXAMPLE, VULNERABLE: the query string is inserted into HTML as is.
export function renderSearchVulnerable(q) {
  return `<p>Results for: ${q}</p>`;
}

export function escapeHtml(value) {
  return String(value).replace(
    /[&<>"']/g,
    (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch],
  );
}

// Fixed: HTML entity encoding for the HTML body context.
export function renderSearch(q) {
  return `<p>Results for: ${escapeHtml(q)}</p>`;
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно. Исправление кодирует пять символов HTML-сущностями — правило шпаргалки OWASP для контекста HTML-тела
terminal
$ npm test
…
vulnerable: <p>Results for: <script>alert(document.cookie)</script></p>
fixed:      <p>Results for: &lt;script&gt;alert(document.cookie)&lt;/script&gt;</p>
Реальный ответ сервера до и после исправления

Экранирование зависит от контекста: для HTML-атрибута, JavaScript, CSS и URL правила разные. Шаблонизаторы и React экранируют текст автоматически, но обходные пути — dangerouslySetInnerHTML, вставка HTML без санитайзера — снова открывают дыру. Второй рубеж — заголовок Content-Security-Policy. Мы проверили оба эффекта в Chromium через Playwright:

terminal
$ node support/browser-check.mjs   # Playwright 1.64 + Chromium
SRI original  → typeof dayjs: function
SRI tampered  → typeof dayjs: undefined
console error: Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.jsdelivr.net/npm/dayjs@1.11.23/dayjs.min.js' with computed SHA-384 integrit
XSS, no CSP   → title: "pwned"
XSS, with CSP → title: ""
console error: Executing inline script violates the following Content Security Policy directive 'default-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-OOZdo
Реальный вывод вспомогательного скрипта проверки (его код не приводим: он только открывает страницы в Chromium и печатает результат; сообщения консоли обрезаны до 160 символов). Без CSP внедрённый скрипт поменял заголовок страницы, с CSP браузер заблокировал inline-скрипт. Первые строки относятся к A08 и SRI (см. ниже)

A04 и A07: криптография, пароли и вход

A04 — ошибки в применении криптографии: данные передаются без TLS, используются устаревшие MD5 и SHA-1, ключи лежат в репозитории, случайность берётся из Math.random, пароли хранятся открытым текстом или быстрым хешем. Рекомендации OWASP: TLS не ниже 1.2 и HSTS, шифрование чувствительных данных при хранении, самые чувствительные ключи — в аппаратном или облачном HSM, а пароли — только адаптивными функциями с солью: Argon2id, scrypt, yescrypt или PBKDF2. bcrypt шпаргалка OWASP оставляет для старых систем.

src/a04-passwords.js
import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

// TRAINING EXAMPLE, VULNERABLE: a fast hash without salt.
export function hashPasswordVulnerable(password) {
  return createHash("sha256").update(password).digest("hex");
}

const scryptAsync = promisify(scrypt);
// OWASP minimum for scrypt: N = 2^17, r = 8, p = 1 (needs ~128 MiB, so raise maxmem).
const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };

export async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scryptAsync(password, salt, 32, PARAMS);
  return ["scrypt", PARAMS.N, PARAMS.r, PARAMS.p, salt.toString("base64"), key.toString("base64")].join("$");
}

export async function verifyPassword(password, stored) {
  const [alg, N, r, p, salt, hash] = stored.split("$");
  if (alg !== "scrypt") return false;
  const expected = Buffer.from(hash, "base64");
  const options = { N: Number(N), r: Number(r), p: Number(p), maxmem: PARAMS.maxmem };
  const actual = await scryptAsync(password, Buffer.from(salt, "base64"), expected.length, options);
  return timingSafeEqual(actual, expected);
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно. Исправление — встроенный в Node.js scrypt с параметрами из шпаргалки OWASP, солью и сравнением за постоянное время

Почему SHA-256 плох именно для паролей: он быстрый, и одинаковые пароли дают одинаковый хеш — по утёкшей таблице видно, у кого совпадают пароли, а перебор идёт с огромной скоростью. scrypt со случайной солью даёт разные хеши для одного пароля и стоит дорого по памяти и времени. Шпаргалка OWASP ставит на первое место Argon2id — его в уроке об аутентификации мы подключали пакетом argon2; scrypt нужен, когда Argon2id недоступен, и не требует зависимостей.

A07 — ошибки аутентификации: система принимает чужого за своего. В списке OWASP — подбор паролей и credential stuffing (вход по утёкшим парам логин–пароль), слабые и стандартные пароли, разные сообщения для «нет пользователя» и «неверный пароль», идентификатор сессии в URL, сессия, которая не меняется после входа и не гаснет после выхода.

src/a07-login.js
// TRAINING EXAMPLE, VULNERABLE: different messages reveal which emails exist,
// and nothing limits the number of attempts.
export async function loginVulnerable(users, email, password, verify) {
  const user = users.get(email);
  if (!user) return { ok: false, message: "User not found" };
  if (!(await verify(password, user.passwordHash))) return { ok: false, message: "Wrong password" };
  return { ok: true, user };
}

// Fixed: one message for every failure and a limit per email + IP.
export function createLoginLimiter({ max = 5, windowMs = 15 * 60 * 1000 } = {}) {
  const attempts = new Map();
  return {
    isBlocked(key, now = Date.now()) {
      const entry = attempts.get(key);
      return Boolean(entry && now - entry.first < windowMs && entry.count >= max);
    },
    fail(key, now = Date.now()) {
      const entry = attempts.get(key);
      if (!entry || now - entry.first >= windowMs) attempts.set(key, { first: now, count: 1 });
      else entry.count += 1;
    },
    reset(key) {
      attempts.delete(key);
    },
  };
}

export async function login(users, limiter, { email, password, ip }, { verify, dummyHash }) {
  const key = `${email}|${ip}`;
  if (limiter.isBlocked(key)) return { ok: false, status: 429, message: "Too many attempts, try later" };
  const user = users.get(email);
  // Hash even for unknown emails, so response time does not reveal them either.
  const valid = await verify(password, user?.passwordHash ?? dummyHash);
  if (!user || !valid) return limitFail(limiter, key);
  limiter.reset(key);
  return { ok: true, status: 200, user };
}

function limitFail(limiter, key) {
  limiter.fail(key);
  return { ok: false, status: 401, message: "Invalid email or password" };
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно. Исправление: одно сообщение для всех отказов, хеширование и для несуществующего e-mail, лимит попыток на пару e-mail + IP
terminal
$ npm test
…
sha256: daaad6e5604e8e17bd9f108d91e26afe6281dac8fda0091040a7a6d7bd9b43b5
scrypt (232 ms): scrypt$131072$8$1$HBwGieqeUUj/t2wm2qaA+g==$5XlbvmafNblQXaR1yBaI45Zp2BSKDv/fOjsPCpOHDYk=
vulnerable: User not found / Wrong password
fixed: Invalid email or password / Invalid email or password
next attempts: 401, 401, 429
Реальный вывод тестов A04 и A07
  • Лимит в памяти процесса — учебный: при нескольких экземплярах приложения счётчики хранят в Redis или в базе. OWASP предупреждает, что ограничение не должно превращаться в отказ в обслуживании для настоящего пользователя.
  • MFA (многофакторная аутентификация) стоит первой в рекомендациях OWASP: она защищает от подбора, credential stuffing и повторного использования украденных паролей.
  • После входа выдавайте новый идентификатор сессии, cookie — с HttpOnly, Secure, SameSite. Это и вход на Express с Argon2id показаны в уроке «Аутентификация: сессии, cookies и JWT».
  • Как проверить: тест «неизвестный e-mail и неверный пароль дают одинаковый ответ», тест на 429 после N попыток, поиск в коде createHash("md5", sha1, Math.random рядом с токенами и паролями.

A02, A03 и A08: конфигурация, зависимости и целостность

A02 — небезопасная конфигурация: включены лишние функции и страницы, не сменены стандартные учётные записи, клиенту уходят трассировки стека, сервер не отправляет защитные заголовки. Документация Express напоминает: встроенный обработчик ошибок отдаёт стек клиенту, если NODE_ENV не равен production. Надёжнее не полагаться на переменную, а поставить свой обработчик:

src/app.js
import { randomUUID } from "node:crypto";
import express from "express";
import { renderSearch, renderSearchVulnerable } from "./a05-xss.js";

export function createApp({ log, vulnerable = false }) {
  const app = express();
  app.disable("x-powered-by");

  app.use((req, res, next) => {
    res.set({
      "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'",
      "X-Content-Type-Options": "nosniff",
      "Strict-Transport-Security": "max-age=63072000; includeSubDomains",
      "Referrer-Policy": "strict-origin-when-cross-origin",
    });
    next();
  });

  app.get("/search", (req, res) => {
    const q = String(req.query.q ?? "");
    res.type("html").send(vulnerable ? renderSearchVulnerable(q) : renderSearch(q));
  });

  app.get("/boom", () => {
    throw new Error("relation \"leads_archive\" does not exist");
  });

  if (vulnerable) {
    // TRAINING EXAMPLE, VULNERABLE: the stack trace goes to the client.
    app.use((err, req, res, _next) => res.status(500).send(err.stack));
  } else {
    // Fixed: details go to the log, the client gets an id to quote to support.
    app.use((err, req, res, _next) => {
      const errorId = randomUUID();
      log.error({ event: "http.unhandled_error", errorId, path: req.path, message: err.message });
      res.status(500).json({ error: "internal_error", errorId });
    });
  }
  return app;
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно (ветка vulnerable). Заголовки — по рекомендациям шпаргалки OWASP HTTP Headers
terminal
$ npm test
…
vulnerable 500: Error: relation "leads_archive" does not exist |     at file:///C:/Users/…/owasp-top-10/src/app.js:25:11
fixed 500: {"error":"internal_error","errorId":"359acb48-a504-4c85-b5e8-7257d721f848"}
Реальный вывод: уязвимая версия раскрывает текст ошибки базы и путь к файлам на сервере, исправленная — только идентификатор для поддержки

Вместо ручной установки заголовков в Express обычно подключают middleware Helmet — его рекомендует руководство Express по безопасности. Как проверить A02: запрос к несуществующему маршруту и к маршруту с ошибкой — в ответе не должно быть стека и версий; проверка заголовков тестом; конфигурация окружений из кода, а не руками.

A03 — сбои цепочки поставок: уязвимые, заброшенные или вредоносные зависимости, небезопасные сборка и CI/CD. В описании категории OWASP приводит самораспространяющийся npm-червь Shai-Hulud 2025 года: заражённые версии популярных пакетов через post-install-скрипт крали данные и токены npm. Базовые меры — lock-файл и npm ci в CI, регулярный npm audit, учёт транзитивных зависимостей (SBOM), обновление по необходимости, а не «всё и сразу». Вот реальная проверка проекта со старой версией lodash:

terminal
$ npm install lodash@4.17.20
$ npm audit --audit-level=high
# npm audit report

lodash  <=4.17.23
Severity: high
Command Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhm
Regular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-29mw-wpgm-hmr9
lodash vulnerable to Code Injection via `_.template` imports key names - https://github.com/advisories/GHSA-r5fr-rjxr-66jc
lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit` - https://github.com/advisories/GHSA-f23m-r3pf-42rh
Lodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions - https://github.com/advisories/GHSA-xxjr-mmjv-4gpg
fix available via `npm audit fix`
node_modules/lodash

1 high severity vulnerability

To address all issues, run:
  npm audit fix

$ npm audit fix
$ npm audit --audit-level=high
found 0 vulnerabilities
Реальный вывод npm 11.16: --audit-level=high возвращает ненулевой код — CI упадёт; после npm audit fix lodash обновился до 4.18.1

Флаг --ignore-scripts (или настройка ignore-scripts=true) не даёт пакетам запускать свои install-скрипты — это закрывает путь атак вроде Shai-Hulud, но ломает пакеты, которым скрипт нужен для сборки, поэтому включайте его осознанно.

A08 — нарушение целостности: код или данные из недоверенного источника считаются доверенными. Примеры OWASP — скрипты с чужих CDN без проверки, автообновления без подписи, CI, который тянет артефакты без проверки, и небезопасная десериализация данных, которые клиент может изменить. Для скриптов с CDN есть Subresource Integrity: браузер сверяет хеш файла с атрибутом integrity.

bash
curl -s https://cdn.jsdelivr.net/npm/dayjs@1.11.23/dayjs.min.js | openssl dgst -sha384 -binary | openssl base64 -A
Хеш для атрибута integrity — команда из документации MDN
public/sri.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>SRI demo</title>
    <script
      src="https://cdn.jsdelivr.net/npm/dayjs@1.11.23/dayjs.min.js"
      integrity="sha384-1Ft4/JTNbt7S/44V0himpcjx+YfQ1a6W6SYBJMr94ba6jwZhWoWoLaZsMmtJILgq"
      crossorigin="anonymous"
    ></script>
  </head>
  <body></body>
</html>
Подключение со стороннего CDN с проверкой целостности

Проверка в браузере — в выводе выше: с правильным хешем dayjs загрузился, с изменённым Chromium заблокировал файл и написал в консоль, что не нашёл подходящий digest. То же правило для данных: не доверять тому, что пришло от клиента и не подписано, — например, роли в неподписанной cookie.

A06, A09 и A10: дизайн, журналы и исключительные ситуации

A06 — небезопасный дизайн: нужного механизма защиты нет вовсе, и идеальная реализация это не исправит. В примере OWASP кинотеатр даёт скидку на групповое бронирование до 15 мест без депозита, а атакующий бронирует 600 мест во всех залах за несколько запросов. Лечится на этапе проектирования: моделирование угроз (threat modeling) для входа, доступа и бизнес-логики, истории «злоупотребления» рядом с пользовательскими и проверки правдоподобия на каждом слое.

src/a06-booking.js
// TRAINING EXAMPLE, VULNERABLE: the business limit exists only in the UI.
export function parseBookingVulnerable(body) {
  return { seats: body.seats };
}

// Fixed: the rule "at most 15 seats without a deposit" lives on the server.
export const MAX_SEATS_WITHOUT_DEPOSIT = 15;

export function parseBooking(body) {
  const seats = Number(body?.seats);
  if (!Number.isInteger(seats) || seats < 1) return { ok: false, error: "invalid_seats" };
  if (seats > MAX_SEATS_WITHOUT_DEPOSIT) return { ok: false, error: "deposit_required" };
  return { ok: true, seats };
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно. Бизнес-ограничение должно жить на сервере, а не только в интерфейсе

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

src/a09-logging.js
// TRAINING EXAMPLE, VULNERABLE: string logs let input forge new lines, and the password leaks.
export function logFailedLoginVulnerable(write, { email, password }) {
  write(`WARN login failed for ${email} with password ${password}\n`);
}

// Fixed: one JSON object per line, no secrets; JSON.stringify escapes line breaks.
export function createLogger(write) {
  const entry = (level) => (fields) =>
    write(JSON.stringify({ time: new Date().toISOString(), level, ...fields }) + "\n");
  return { info: entry("info"), warn: entry("warn"), error: entry("error") };
}

export function logFailedLogin(log, { email, ip }) {
  log.warn({ event: "auth.login_failed", email, ip });
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно. Исправление — структурированный журнал: одно событие JSON на строку, без паролей

A10 — новая категория 2025 года: неправильная обработка исключительных ситуаций. Если ошибка проглатывается, система оказывается в непредсказуемом состоянии, а опаснее всего — «отказ в открытую» (fail open): проверка упала, а действие выполнилось. OWASP требует обратного — fail closed: при ошибке откатывать транзакцию целиком, логировать событие и иметь глобальный обработчик ошибок.

src/a10-errors.js
// TRAINING EXAMPLE, VULNERABLE: if the ownership check throws, the request goes through (fail open).
export function requireOwnerVulnerable(isOwner) {
  return async (req, res, next) => {
    try {
      if (!(await isOwner(req.user.id, req.params.id))) return res.status(404).end();
    } catch (err) {
      console.error(err);
    }
    next();
  };
}

// Fixed (fail closed): no swallowed errors. In Express 5 a rejected async middleware
// goes straight to the error handler, and the route below never runs.
export function requireOwner(isOwner) {
  return async (req, res, next) => {
    if (!(await isOwner(req.user.id, req.params.id))) return res.status(404).end();
    next();
  };
}
Учебный уязвимый код: функции с суффиксом Vulnerable написаны неправильно намеренно. В Express 5 отклонённый промис из middleware сам передаётся в обработчик ошибок
terminal
$ npm test
…
WARN login failed for x@example.com
INFO login ok for admin@example.com with password hunter2
{"time":"2026-10-12T01:44:45.765Z","level":"warn","event":"auth.login_failed","email":"x@example.com\nINFO login ok for admin@example.com","ip":"10.0.0.1"}
vulnerable: 200 {"deleted":"3"} | fixed: 500 {"error":"internal_error"}
Реальный вывод: строковый лог получил поддельную строку «INFO login ok» и пароль; JSON-лог сохранил перевод строки внутри поля. При «упавшей» базе уязвимый middleware пропустил удаление, исправленный вернул 500

Как проверить: для A06 — тесты на злоупотребление бизнес-правилами (отрицательные и огромные количества, повтор запроса); для A09 — тест, что неудачный вход порождает событие без пароля, и правило оповещения на всплеск таких событий; для A10 — тесты с подменённой упавшей зависимостью: база, внешний API, таймаут. Ошибка должна давать отказ, а не успех.

Как проверить проект: инструменты и чек-лист

terminal
$ npm test
…
ℹ tests 17
ℹ pass 17
ℹ fail 0
Итог прогона на стенде: 17 тестов, каждая пара «уязвимо → исправлено» подтверждена

Тесты на запрет и злоупотребление

Что находит
A01, A06, A07, A10: чужие записи, лимиты, отказ при ошибке
Когда
На каждый pull request

npm audit --audit-level=high, SBOM

Что находит
A03: известные уязвимости в зависимостях
Когда
В CI и по расписанию

SAST и code review

Что находит
A04, A05: склейка SQL, слабые хеши, секреты в коде
Когда
На каждый pull request

DAST (например, OWASP ZAP)

Что находит
A02, A05: заголовки, ошибки, отражённый ввод
Когда
На тестовом стенде перед релизом

ASVS 5.0.0

Что находит
Полный список требований по уровням
Когда
При проектировании и аудите
  • Каждый маршрут с идентификатором проверяет владельца на сервере; всё непубличное запрещено по умолчанию.
  • Исходящие запросы по адресам от пользователя идут только на разрешённые хосты.
  • Все SQL-запросы параметризованы; имена колонок — из списка разрешённых.
  • Вывод экранируется по контексту; включён Content-Security-Policy.
  • Пароли — Argon2id или scrypt с солью; секреты — не в репозитории; везде HTTPS и HSTS.
  • Одно сообщение при неудачном входе, лимит попыток, новая сессия после входа, MFA для админов.
  • Клиент не видит стеков и версий; x-powered-by выключен; защитные заголовки заданы.
  • npm ci и npm audit в CI; lock-файл в репозитории; сторонние скрипты — с integrity.
  • Бизнес-ограничения проверяются на сервере; для входа, доступа и оплаты есть модель угроз.
  • Неудачные входы и отказы доступа пишутся структурированно, без секретов; есть оповещения.
  • Ошибка проверки или зависимости приводит к отказу и откату транзакции, а не к успеху.

Дальше: хранение секретов и переменных окружения, безопасность интеграций с подписями вебхуков и отдельная тема — безопасность LLM-приложений: инъекции промптов OWASP разбирает в собственном списке для LLM.

Как проверено5 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    db/schema.sql и команды стенда

    postgres:17-alpine (PostgreSQL 17.11) в Docker, порт 55432, схема применена через psql. В статье имя контейнера owasp-lab, при проверке — kb-pg-owasp-top-10 (правило изоляции), остальное идентично

  • запущено, работает

    src/a01-access.js, src/a05-injection.js (SQLi на pg и параметризация), src/a05-xss.js, src/a04-passwords.js (scrypt), src/a07-login.js, src/app.js (A02), src/a06-booking.js, src/a09-logging.js, src/a10-errors.js, test/*.test.js

    Node.js 24.18, express 5.3.0, pg 8.23.1, node --test --test-concurrency=1: 17 тестов, 17 pass. Уязвимые версии воспроизводят атаку (OR 1=1 — 3 заявки, UNION — e-mail и хеши, XSS — <script> в ответе, стек и путь в ответе 500, fail open — 200 на DELETE), исправленные её закрывают

  • запущено, работает

    XSS с CSP и без, SRI (public/sri.html, dayjs 1.11.23 с jsdelivr)

    вспомогательный скрипт support/browser-check.mjs на Playwright 1.64 + Chromium 156: без CSP заголовок страницы стал pwned, с CSP inline-скрипт заблокирован; SRI с верным хешем — dayjs загружен, с изменённым — заблокирован. Код скрипта в статье не приводится (оговорено в подписи)

  • запущено, работает

    A03: npm audit на lodash@4.17.20

    npm 11.16: audit --audit-level=high — 1 high, код выхода 1; npm audit fix — lodash 4.18.1, 0 vulnerabilities

  • запущено, работает

    Упражнение: направление сортировки из двух констант

    exercises/direction.test.js на том же стенде — pass, таблица на месте

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

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

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