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 г.
Содержание
Что такое 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 — обработка исключительных ситуаций
- Суть
- Ошибка приводит к «открытому» состоянию или утечке
- Что изменилось
- Новая категория
Ниже по каждой категории — суть, пара «уязвимый код → исправление» на Node.js 24, Express 5 и PostgreSQL 17 и способ проверки. Уязвимые и исправленные функции лежат рядом, а тесты доказывают разницу. Стенд поднимается в Docker:
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-labnode:testCREATE 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');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)) };
}A01: нарушение контроля доступа
Контроль доступа следит, чтобы пользователь не выходил за пределы своих прав. Типичные провалы по OWASP: доступ к чужой записи по её идентификатору (IDOR — insecure direct object reference), открытые для всех POST, PUT и DELETE в API, проверки только на фронтенде, подмена JWT или cookie, неверный CORS, «угадывание» адресов админки. Главное правило из рекомендаций: проверка работает, только если она выполняется на сервере, и всё, кроме публичного, по умолчанию запрещено.
// 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 выше делает именно это и сравнивает имя хоста целиком, а не по подстроке.
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/);
}
});Как проверить: на каждый маршрут с идентификатором — тест «второй пользователь получает 404/403». OWASP прямо советует включать функциональные проверки доступа в unit- и интеграционные тесты, а отказы доступа — логировать.
A05: инъекции — SQL и XSS
Инъекция возникает, когда пользовательские данные попадают в интерпретатор — базу данных, командную оболочку, браузер — и часть данных выполняется как команда. Защита по OWASP одна по сути: держать данные отдельно от команд. Начнём с SQL. Уязвимая функция собирает запрос шаблонной строкой:
// 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 и $2import 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"]);
});$ 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)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> из адреса выполняется на странице:
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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-тела$ npm test
…
vulnerable: <p>Results for: <script>alert(document.cookie)</script></p>
fixed: <p>Results for: <script>alert(document.cookie)</script></p>Экранирование зависит от контекста: для HTML-атрибута, JavaScript, CSS и URL правила разные. Шаблонизаторы и React экранируют текст автоматически, но обходные пути — dangerouslySetInnerHTML, вставка HTML без санитайзера — снова открывают дыру. Второй рубеж — заголовок Content-Security-Policy. Мы проверили оба эффекта в Chromium через Playwright:
$ 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-OOZdoA04 и A07: криптография, пароли и вход
A04 — ошибки в применении криптографии: данные передаются без TLS, используются устаревшие MD5 и SHA-1, ключи лежат в репозитории, случайность берётся из Math.random, пароли хранятся открытым текстом или быстрым хешем. Рекомендации OWASP: TLS не ниже 1.2 и HSTS, шифрование чувствительных данных при хранении, самые чувствительные ключи — в аппаратном или облачном HSM, а пароли — только адаптивными функциями с солью: Argon2id, scrypt, yescrypt или PBKDF2. bcrypt шпаргалка OWASP оставляет для старых систем.
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, сессия, которая не меняется после входа и не гаснет после выхода.
// 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$ 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- Лимит в памяти процесса — учебный: при нескольких экземплярах приложения счётчики хранят в 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. Надёжнее не полагаться на переменную, а поставить свой обработчик:
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$ 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:
$ 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--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.
curl -s https://cdn.jsdelivr.net/npm/dayjs@1.11.23/dayjs.min.js | openssl dgst -sha384 -binary | openssl base64 -A<!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>Проверка в браузере — в выводе выше: с правильным хешем dayjs загрузился, с изменённым Chromium заблокировал файл и написал в консоль, что не нашёл подходящий digest. То же правило для данных: не доверять тому, что пришло от клиента и не подписано, — например, роли в неподписанной cookie.
A06, A09 и A10: дизайн, журналы и исключительные ситуации
A06 — небезопасный дизайн: нужного механизма защиты нет вовсе, и идеальная реализация это не исправит. В примере OWASP кинотеатр даёт скидку на групповое бронирование до 15 мест без депозита, а атакующий бронирует 600 мест во всех залах за несколько запросов. Лечится на этапе проектирования: моделирование угроз (threat modeling) для входа, доступа и бизнес-логики, истории «злоупотребления» рядом с пользовательскими и проверки правдоподобия на каждом слое.
// 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 подчёркивает: хорошие логи без оповещений почти бесполезны. Отдельный риск — сами логи: секреты и персональные данные в них, а также внедрение поддельных строк через перевод строки во вводе.
// 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: при ошибке откатывать транзакцию целиком, логировать событие и иметь глобальный обработчик ошибок.
// 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 сам передаётся в обработчик ошибок$ 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"}Как проверить: для A06 — тесты на злоупотребление бизнес-правилами (отрицательные и огромные количества, повтор запроса); для A09 — тест, что неудачный вход порождает событие без пароля, и правило оповещения на всплеск таких событий; для A10 — тесты с подменённой упавшей зависимостью: база, внешний API, таймаут. Ошибка должна давать отказ, а не успех.
Как проверить проект: инструменты и чек-лист
$ npm test
…
ℹ tests 17
ℹ pass 17
ℹ fail 0Тесты на запрет и злоупотребление
- Что находит
- 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.