OWASP Top 10 — это рейтинг самых разрушительных классов уязвимостей веб-приложений, который Open Web Application Security Project обновляет раз в несколько лет на основе данных сотен тысяч пентестов и багбаунти-репортов. Текущая редакция датирована 2021 годом, и пока (на момент 2026) она остаётся актуальной — категории сместились, но суть та же. Большинство компрометаций сайтов в РФ — это банальный SQL-injection через формы поиска, кривая авторизация в админке, предсказуемые ID объектов и устаревший npm-пакет в node_modules. В этом посте — детальный разбор каждой из десяти категорий с примерами кода атак, защитой, реальными CVE 2024-2025 и большим разделом про Content Security Policy в современном Next.js.
Материал рассчитан на разработчиков, тех-лидов и security-инженеров, которые проектируют и поддерживают веб-приложения на Node.js, Go, Python, Java или PHP. Примеры кода даны на TypeScript/Node и Go там, где это уместно — принципы переносятся на любой язык один в один.
A01 — Broken Access Control: Сломанный контроль доступа
Эта категория с 2021 года заняла первое место и удерживает его. Суть проста: сервер не проверяет, что пользователь имеет право на запрашиваемый ресурс. Самый частый подвид — IDOR (Insecure Direct Object Reference): пользователь A меняет в URL userId=123 на userId=124 и получает данные пользователя B. Сюда же входят: отсутствие проверки роли (обычный юзер вызывает админский эндпоинт), force browsing (доступ к скрытым URL без линков из UI), CORS-обход, обход через путь (path traversal), небезопасные методы (PUT/DELETE доступны без авторизации).
Вторая распространённая проблема — проверка прав на клиенте. «Кнопка удалить» спрятана в React по if (user.role === "admin"), но эндпоинт DELETE /api/users/:id не проверяет роль на сервере. Любой может открыть DevTools, увидеть запрос и повторить его руками.
Пример уязвимого кода (Express):
// ПЛОХО: нет проверки владельца
app.get("/api/orders/:id", async (req, res) => {
const order = await db.order.findUnique({ where: { id: req.params.id } });
res.json(order);
});
Любой авторизованный пользователь, перебирая :id, читает чужие заказы. Атака — обычный for цикл с инкрементом ID и логированием 200-х ответов.
Защита — middleware с обязательной проверкой принадлежности:
// ХОРОШО: проверка владельца + RBAC
app.get("/api/orders/:id", requireAuth, async (req, res) => {
const order = await db.order.findUnique({ where: { id: req.params.id } });
if (!order) return res.status(404).end();
if (order.userId !== req.user.id && req.user.role !== "admin") {
return res.status(403).json({ error: "forbidden" });
}
res.json(order);
});
Дополнительные практики: использовать UUIDv4 или ULID вместо инкрементальных ID (атака усложняется на порядки), строить ABAC/RBAC через единый policy-движок (Casbin, OPA), писать интеграционные тесты на негативные сценарии («юзер B не может прочитать заказ юзера A»).
Реальный кейс 2024 года — серия отчётов на HackerOne про IDOR в популярных SaaS, где смена team_id в URL давала доступ к чужим инвойсам. По данным OWASP, 94% протестированных приложений в 2021-2024 содержали хотя бы одну форму broken access control.
A02 — Cryptographic Failures: Криптографические провалы
Прежнее название — Sensitive Data Exposure. Сюда входит всё, что связано с защитой данных в покое и в передаче: слабые алгоритмы (MD5, SHA1 для паролей), хранение паролей в plaintext или в обратимом виде, отсутствие TLS, использование самоподписанных сертификатов в проде, утечка ключей в Git, слабые TLS-ciphers (RC4, 3DES), отсутствие forward secrecy.
Классический антипаттерн — SHA1 или MD5 для паролей:
// ПЛОХО: MD5 ломается за секунды на rainbow tables
import crypto from "crypto";
const hash = crypto.createHash("md5").update(password).digest("hex");
Атакующий с дампом БД скачивает rainbow table на 14 ГБ и за вечер восстанавливает 80% паролей. Соль не спасёт, если алгоритм быстрый: MD5 на современной GPU считается со скоростью 200 миллиардов хэшей в секунду.
Правильно — argon2id (победитель Password Hashing Competition) или bcrypt:
// ХОРОШО: argon2id с разумными параметрами
import argon2 from "argon2";
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
});
// Проверка
const ok = await argon2.verify(hash, password);
Параметры argon2id подбираются под железо: цель — около 100-500 мс на хэширование одного пароля. Меньше — слишком быстро для атакующего, больше — лагает форма логина.
По TLS: только TLS 1.2+, лучше 1.3; отключить старые ciphers через nginx/caddy; HSTS-заголовок с max-age=63072000; includeSubDomains; preload; регулярная проверка через ssllabs.com (цель — A+).
Секреты: никогда в Git. Используйте Vault, AWS Secrets Manager, Yandex Lockbox, doppler.com. На худой конец — .env в .gitignore плюс sealed-secrets в k8s. Если ключ всё-таки утёк — немедленно ротируйте, не «закоммитим удаление».
Реальный CVE: CVE-2024-31497 (PuTTY) — генерация ECDSA-подписей с предсказуемым nonce, что позволило восстановить приватный ключ из 60 подписей. Урок: даже зрелые криптобиблиотеки иногда содержат провалы — следите за advisory вашего стека.
A03 — Injection: Инъекции
SQL-injection, NoSQL-injection, LDAP-injection, XPath-injection, OS command injection, ORM-injection, prompt-injection в LLM-фичах. Категория опустилась с первого места на третье не потому, что её стало меньше, а потому что современные фреймворки и ORM закрывают её по умолчанию. Но как только разработчик пишет «raw SQL для оптимизации» или собирает shell-команду конкатенацией — дыра возвращается.
SQL-injection в Node.js (классика):
// ПЛОХО: конкатенация пользовательского ввода
app.get("/search", async (req, res) => {
const q = req.query.q;
const sql = `SELECT * FROM products WHERE name LIKE '%${q}%'`;
const rows = await db.raw(sql);
res.json(rows);
});
Атака: ?q=%27%20UNION%20SELECT%20email,password%20FROM%20users-- — и в ответе летит дамп пользователей. UNION-based SQLi автоматизируется sqlmap-ом за минуты.
Защита — параметризованные запросы:
// ХОРОШО: prepared statement через драйвер
const rows = await db.query(
"SELECT * FROM products WHERE name LIKE $1",
[`%${q}%`]
);
// Или через ORM (Prisma)
const rows = await prisma.product.findMany({
where: { name: { contains: q } },
});
OS command injection — отдельный класс, особенно в Node.js с child_process.exec:
// ПЛОХО: shell интерпретирует метасимволы
import { exec } from "child_process";
exec(`convert ${userFile} -resize 200x200 thumb.jpg`);
// Атака: userFile = "img.png; rm -rf /; #"
Защита — execFile или spawn с массивом аргументов (без shell-интерпретации):
// ХОРОШО: аргументы передаются как массив
import { execFile } from "child_process";
execFile("convert", [userFile, "-resize", "200x200", "thumb.jpg"]);
Плюс валидация имени файла по белому списку расширений и регэкспу (/^[a-zA-Z0-9_-]+\.(png|jpg|webp)$/).
Реальный CVE 2024: CVE-2024-4577 — argument injection в PHP-CGI на Windows. Удалённое выполнение кода через специально сформированные query-параметры. Активно эксплуатировалось в дикой природе, попало в CISA Known Exploited Vulnerabilities. Урок: даже «надёжные» древние стеки иногда выкатывают RCE-класса дыры.
A04 — Insecure Design: Небезопасный дизайн
Самая неоднозначная категория. Здесь не ошибка реализации, а ошибка проектирования: бизнес-логика построена так, что её можно злоупотребить, даже если код написан идеально. Примеры: корзина считается на клиенте и можно подменить цену, регистрация без подтверждения email позволяет создать тысячи аккаунтов, восстановление пароля через секретный вопрос «девичья фамилия матери», денежный перевод без идемпотентного ключа допускает race condition.
Race condition в переводе денег — классика:
// ПЛОХО: чтение и запись в две операции без блокировки
async function transfer(fromId: string, toId: string, amount: number) {
const from = await db.account.findUnique({ where: { id: fromId } });
if (from.balance < amount) throw new Error("insufficient");
await db.account.update({ where: { id: fromId }, data: { balance: from.balance - amount } });
await db.account.update({ where: { id: toId }, data: { balance: { increment: amount } } });
}
Атака: 50 параллельных запросов на перевод одной и той же суммы. Все 50 пройдут проверку from.balance < amount, потому что обновление ещё не записалось. Баланс уйдёт в минус, атакующий получит деньги «из воздуха».
Защита — транзакция с пессимистичной блокировкой и атомарным условием:
// ХОРОШО: транзакция + атомарный декремент с проверкой
await db.$transaction(async (tx) => {
const updated = await tx.account.updateMany({
where: { id: fromId, balance: { gte: amount } },
data: { balance: { decrement: amount } },
});
if (updated.count === 0) throw new Error("insufficient");
await tx.account.update({
where: { id: toId },
data: { balance: { increment: amount } },
});
});
Плюс throttling на уровне аккаунта (один перевод в N секунд), идемпотентный ключ (Idempotency-Key в заголовке), audit-лог с до/после-балансами.
Методология: STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) — для каждой фичи на этапе дизайна пройтись по шести категориям. Результат — short-list угроз и митигаций до того, как код написан. Альтернативы — PASTA, LINDDUN. На практике достаточно полчаса воркшопа с тех-лидом и продактом перед каждой крупной фичей.
A05 — Security Misconfiguration: Неверная конфигурация
Самая «дешёвая» уязвимость для атакующего и самая обидная для команды. Примеры: открытые админки на дефолтных URL (/admin, /phpmyadmin), дефолтные пароли (admin/admin), DEBUG=True в проде с полным стек-трейсом и переменными окружения, открытый Elasticsearch без auth на 9200, S3-бакет с правами public-read, незакрытые порты СУБД наружу, отсутствие security-заголовков, забытые тестовые эндпоинты.
Особенно болезненный случай — misconfigured CORS при credentials:
// ПЛОХО: разрешает любому сайту слать запросы с куками юзера
app.use(cors({
origin: "*",
credentials: true,
}));
Браузер технически блокирует Access-Control-Allow-Origin: * вместе с credentials: true, но многие фреймворки рефлектят Origin из запроса:
// ПЛОХО: рефлект Origin = разрешено всем
app.use(cors({
origin: (origin, cb) => cb(null, origin),
credentials: true,
}));
Любой атакующий сайт evil.com через fetch("https://your-app.com/api/me", { credentials: "include" }) читает приватные данные жертвы.
Правильно — явный allowlist:
// ХОРОШО: явный список доверенных origin
const ALLOWED = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true); // server-to-server
cb(null, ALLOWED.has(origin));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
maxAge: 600,
}));
Чек-лист конфигурации перед релизом: убраны debug-флаги; стек-трейсы не уходят клиенту (только request-id для саппорта); удалены тестовые учётки; админки за VPN или basic-auth на nginx; Postgres/Redis не торчат в интернет (only 127.0.0.1 или внутренняя сеть docker); сменены дефолтные пароли БД; включены все security-заголовки (см. ниже раздел про HTTP-headers); регулярный скан портов через nmap/masscan со стороны.
A06 — Vulnerable and Outdated Components: Уязвимые зависимости
Современное веб-приложение — это сотни npm-пакетов, и каждый — потенциальный вектор. Уязвимость в популярной библиотеке (lodash, axios, ws, jsonwebtoken) автоматически становится уязвимостью вашего сайта. Атакующие знают это и активно мониторят CVE-фиды, чтобы эксплуатировать сайты до того, как разработчики обновятся.
Базовые инструменты: npm audit или pnpm audit в CI (fail на high+); GitHub Dependabot или Renovate (автоматические PR с обновлениями); Snyk, Trivy, OSV-Scanner для глубокого SCA; GitHub Advisory Database (https://github.com/advisories) для ручных проверок; для Go — govulncheck; для Python — pip-audit.
Минимальная настройка в package.json:
{
"scripts": {
"audit:ci": "npm audit --audit-level=high --omit=dev"
}
}
И шаг в GitHub Actions:
- name: Audit dependencies
run: npm run audit:ci
Renovate config (.github/renovate.json):
{
"extends": ["config:recommended", ":dependencyDashboard"],
"vulnerabilityAlerts": { "enabled": true, "labels": ["security"] },
"schedule": ["before 5am on Monday"],
"packageRules": [
{ "matchUpdateTypes": ["patch"], "automerge": true },
{ "matchPackagePatterns": ["lint", "prettier"], "automerge": true }
]
}
Реальные CVE последних лет: CVE-2024-3094 (xz utils backdoor — см. раздел A08), CVE-2024-21538 (cross-spawn ReDoS), CVE-2024-29415 (ip-package SSRF bypass), CVE-2025-29927 (Next.js — обход middleware через спецзаголовок x-middleware-subrequest, патч в 14.2.25 / 15.2.3 — обновлять немедленно если у вас старее). Урок: подписка на advisory вашего фреймворка и runtime — обязательна.
A07 — Identification and Authentication Failures: Сбои аутентификации
Слабые пароли, отсутствие rate-limit на /login, незащищённые JWT (alg: none, слабый секрет), отсутствие MFA, session fixation, длинные TTL сессий без ротации, восстановление пароля через предсказуемые токены, «remember me» на 365 дней без ротации.
Credential stuffing — типовая атака 2024-2026: атакующий берёт дамп паролей из утечки сервиса X (их сотни в открытом доступе), прогоняет через ваш /login и заходит в 1-3% аккаунтов, где люди использовали тот же пароль. Без rate-limit и без проверки в haveibeenpwned.com защититься нельзя.
Базовый rate-limit в Express через express-rate-limit + Redis:
// ХОРОШО: лимит 10 попыток за 15 минут на IP, 5 на email
import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";
const loginLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: (req) => `login:ip:${req.ip}`,
standardHeaders: true,
legacyHeaders: false,
});
const emailLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
windowMs: 15 * 60 * 1000,
max: 5,
keyGenerator: (req) => `login:email:${req.body?.email ?? "unknown"}`,
});
app.post("/login", loginLimiter, emailLimiter, loginHandler);
Дополнительно: блокировка аккаунта после N провалов с email-уведомлением владельцу; обязательный MFA для админских ролей (TOTP через otplib или WebAuthn); проверка пароля при регистрации против списка топ-10000 утёкших паролей; passwordless через magic-link для сайтов с низкой частотой логина; JWT с коротким TTL (15 минут) + refresh-token с ротацией и revocation list.
JWT-ловушки:
// ПЛОХО: верификация без явного указания алгоритма
jwt.verify(token, secret); // принимает alg:none и HS256-через-публичный-ключ-RSA
// ХОРОШО: явный алгоритм
jwt.verify(token, secret, { algorithms: ["HS256"] });
Сессионные куки — обязательно HttpOnly, Secure, SameSite=Lax (или Strict для админок), __Host- префикс для дополнительной защиты от поддоменных атак.
A08 — Software and Data Integrity Failures: Целостность ПО и данных
Категория появилась в 2021 после серии supply-chain атак (SolarWinds, event-stream). Суть: код или данные доставляются без проверки целостности — и атакующий может подменить пакет, образ, обновление, конфиг. Сюда же insecure deserialization (PHP unserialize, Java readObject, Python pickle).
Showcase 2024 — CVE-2024-3094 (xz utils backdoor). Злоумышленник под ником «Jia Tan» в течение двух лет постепенно становился мейнтейнером популярной библиотеки сжатия xz/liblzma, которая транзитивно линкуется в OpenSSH через systemd на большинстве дистрибутивов Linux. В версиях 5.6.0 и 5.6.1 он встроил бэкдор: специально сформированные тестовые файлы в репозитории при сборке через autotools модифицировали shared object и добавляли проверку публичного ключа атакующего в SSH-аутентификацию. Любой сервер с этими версиями стал уязвим к удалённому входу для держателя приватного ключа. Случайно обнаружено инженером Microsoft Andres Freund по 500-миллисекундной задержке логина и аномалии в Valgrind. Patch — откат на 5.4.x, дистрибутивы выкатили обновления за 48 часов.
Уроки CVE-2024-3094:
- Социальная инженерия мейнтейнерства — реальный вектор, не только код.
- Полное доверие транзитивным зависимостям опасно: glibc, OpenSSL, systemd — каждый тянет десятки пакетов с горсткой мейнтейнеров.
- SBOM (Software Bill of Materials) обязателен для критичных систем — генерируйте через
syft, храните вместе с релизом. - Reproducible builds: если бинарь, собранный из исходников, отличается от опубликованного — звоночек.
- Sigstore / cosign для подписи контейнеров и артефактов.
Insecure deserialization в Node.js менее распространён (JSON безопаснее), но выстреливает с библиотеками типа node-serialize:
// ПЛОХО: serialize с функциями = RCE при unserialize
import serialize from "node-serialize";
const data = serialize.unserialize(req.body); // RCE если в body есть IIFE
Защита — никогда не десериализуйте недоверенные данные форматами с поддержкой функций/классов. JSON + Zod-валидация — стандарт.
// ХОРОШО: JSON + строгая схема
import { z } from "zod";
const Schema = z.object({
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150),
});
const data = Schema.parse(JSON.parse(req.body));
Для CI: подписывайте коммиты через GPG/Sigstore, требуйте signed commits в branch protection, генерируйте SBOM на каждый релиз, храните контрольные суммы Docker-образов в Cosign.
A09 — Security Logging and Monitoring Failures: Логи и мониторинг
В среднем атакующий находится в скомпрометированной системе 200+ дней до обнаружения. Главная причина — нет логов или логи никто не смотрит. Категория не про прямую уязвимость, а про невозможность её детектировать.
Минимальный набор событий для аудит-лога:
- Попытки логина: успех + провал, IP, user-agent, timestamp.
- Изменения прав/ролей: кто кому что выдал/отозвал, до/после.
- Доступ к чувствительным данным: чтение PII, экспорт базы, скачивание отчётов.
- Изменения конфигурации, secrets, feature flags.
- Финансовые операции: транзакции, переводы, возвраты.
- Нестандартные ошибки: 500, 401-всплески, anomalies в latency.
Минимальный pipeline — структурированные логи (JSON) + ELK/Loki/Grafana + алерты в Telegram/PagerDuty. Для бюджетных команд — Sentry для ошибок + хостовый rsyslog с retention 90+ дней + ручной grep раз в неделю. Для зрелых — SIEM (Wazuh, Splunk, ElasticSecurity) с корреляционными правилами.
Пример middleware для Express (структурированный лог + request-id):
import pino from "pino";
import { randomUUID } from "crypto";
const logger = pino({ level: "info" });
app.use((req, res, next) => {
const reqId = req.headers["x-request-id"] ?? randomUUID();
req.log = logger.child({ reqId, ip: req.ip, ua: req.get("user-agent") });
res.setHeader("X-Request-Id", String(reqId));
const start = Date.now();
res.on("finish", () => {
req.log.info({
method: req.method,
path: req.path,
status: res.statusCode,
ms: Date.now() - start,
}, "request");
});
next();
});
Алерты-минимум: всплеск 500 (+200% от baseline за 5 минут), всплеск 401/403 (попытка перебора), новый user-agent на админ-эндпоинте, географическая аномалия логина (логин из РФ → через час из Китая).
Что не логируется никогда: пароли, полные токены, тела платёжных запросов с CVV, JWT целиком (только хэш или первые символы), любые PII без явной необходимости. Маскирование на уровне logger — обязательно (pino-redact или ручной serializer).
A10 — Server-Side Request Forgery (SSRF)
SSRF — когда сервер по запросу пользователя делает HTTP/TCP-запрос на произвольный URL. Атакующий заставляет сервер ходить во внутреннюю сеть: к metadata-сервисам облака, к Redis/Postgres на localhost, к internal-API без auth. Особенно опасно в облаке: AWS, Yandex Cloud, GCP отдают IAM-credentials через 169.254.169.254/latest/meta-data/ без аутентификации — кому угодно с доступом к этому endpoint.
Типовой уязвимый код — превью ссылки или загрузка по URL:
// ПЛОХО: fetch на любой URL от пользователя
app.post("/api/preview", async (req, res) => {
const { url } = req.body;
const response = await fetch(url);
const html = await response.text();
res.json({ title: extractTitle(html) });
});
Атаки:
url=http://169.254.169.254/latest/meta-data/iam/security-credentials/— кража IAM-токена облака.url=http://localhost:6379/+ хитрый payload через CRLF — выполнение команд в Redis.url=http://internal-admin.local/users— доступ к internal-API.url=file:///etc/passwd— чтение локальных файлов (если поддерживается).- DNS rebinding: домен резолвится сначала в публичный IP (проверка пройдена), потом в
127.0.0.1(запрос ушёл во внутрь).
Защита — отдельный fetch-wrapper с allowlist схем, deny private IPs и таймаутами:
// ХОРОШО: безопасный fetch с проверками
import dns from "node:dns/promises";
import net from "node:net";
import ipaddr from "ipaddr.js";
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const PRIVATE_RANGES = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"127.0.0.0/8", "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10"];
async function safeFetch(rawUrl: string) {
const url = new URL(rawUrl);
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
throw new Error("protocol not allowed");
}
// Резолвим хост и проверяем все IP
const records = await dns.lookup(url.hostname, { all: true });
for (const r of records) {
const addr = ipaddr.parse(r.address);
for (const range of PRIVATE_RANGES) {
const [net, bits] = ipaddr.parseCIDR(range);
if (addr.kind() === net.kind() && addr.match(net, bits)) {
throw new Error(`private IP blocked: ${r.address}`);
}
}
}
// Pin IP, чтобы защититься от DNS rebinding
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
return await fetch(rawUrl, {
signal: controller.signal,
redirect: "manual", // редиректы валидируем отдельно
headers: { "User-Agent": "MyApp-LinkPreview/1.0" },
});
} finally {
clearTimeout(timeout);
}
}
Дополнительные меры:
- Выделенный egress-proxy (squid с allowlist) для всех исходящих запросов от приложения.
- На AWS — IMDSv2 (требует токен через PUT-запрос, защищает от классического SSRF).
- На Yandex Cloud — security-group, запрещающая исходящие к metadata-IP с большинства подов.
- Network policies в k8s: запретить egress в
169.254.0.0/16и приватные подсети.
Content Security Policy (CSP) — большой раздел
CSP — это HTTP-заголовок (или meta-тег), который сообщает браузеру белый список источников контента: откуда можно грузить скрипты, стили, картинки, шрифты, к каким хостам разрешён fetch/XHR/WebSocket. Цель — убить целый класс атак: stored XSS, reflected XSS, data-injection, clickjacking, mixed content. Даже если атакующий смог инжектнуть <script> на страницу, браузер откажется его выполнять, если CSP не разрешает.
Базовые директивы:
default-src— fallback для всего, что не указано явно.script-src— разрешённые источники JS.style-src— разрешённые источники CSS.img-src— разрешённые источники картинок.font-src— шрифты.connect-src—fetch,XHR, WebSocket,EventSource.frame-src/child-src— что можно встраивать в iframe.frame-ancestors— кто может встраивать НАС в iframe (заменяет X-Frame-Options).form-action— куда можно отправлять формы.base-uri— что можно ставить в<base href>(защита от base-tag injection).object-src 'none'— отключить Flash/applet (всегдаnone).upgrade-insecure-requests— автоматически апгрейдить http в https.block-all-mixed-content— жёстко блокировать mixed content.report-uri/report-to— куда слать отчёты о нарушениях.
Три стратегии защиты скриптов:
-
Allowlist — перечислить домены:
script-src 'self' https://cdn.example.com https://www.googletagmanager.com. Просто, но дырявит CSP: любой XSS на белом домене ломает защиту, и Google CDN исторически содержал JSONP-эндпоинты, через которые обходили allowlist. -
Hash — хэш от inline-скрипта:
script-src 'sha256-abc123...'. Подходит для статики с фиксированными скриптами (Hugo, Jekyll). Не подходит для динамических SPA. -
Nonce — одноразовый токен, генерируемый на каждый запрос:
script-src 'nonce-randomBase64'. Сервер вставляет тот же nonce в<script nonce="randomBase64">. Атакующий не может предугадать nonce — inline-инъекция не выполнится. Это рекомендованный Google «strict CSP» подход.
Strict CSP в Next.js 15 через middleware:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data: https:;
font-src 'self' data: https://fonts.gstatic.com;
connect-src 'self' https://mc.yandex.ru https://api.example.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
object-src 'none';
upgrade-insecure-requests;
report-uri /api/csp-report;
`.replace(/\s{2,}/g, " ").trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", cspHeader);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", cspHeader);
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
Чтение nonce в layout:
// app/layout.tsx
import { headers } from "next/headers";
import Script from "next/script";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="ru">
<body>
{children}
<Script src="https://mc.yandex.ru/metrika/tag.js" nonce={nonce} strategy="afterInteractive" />
</body>
</html>
);
}
Здесь 'strict-dynamic' означает: «если скрипт с правильным nonce загрузил другой скрипт через document.createElement('script'), тот тоже разрешён». Это позволяет работать React, Next, аналитике без полной allowlist-портянки.
Migration: новый CSP всегда катится через Content-Security-Policy-Report-Only сначала. Браузер не блокирует, но шлёт отчёты на /api/csp-report. Неделю-две собираете нарушения, чините легитимные (свой код, шрифты, аналитика), потом переключаете на enforce-режим.
Endpoint для report:
// app/api/csp-report/route.ts
export async function POST(req: Request) {
const report = await req.json().catch(() => null);
if (report) {
console.warn("CSP violation:", JSON.stringify(report));
// в проде — в Sentry / ELK / отдельную таблицу
}
return new Response(null, { status: 204 });
}
Trusted Types — следующий уровень защиты от DOM-based XSS. Браузер запрещает присваивать строки в опасные DOM-sink (innerHTML, outerHTML, eval, setTimeout(string)). Передавать можно только объекты типа TrustedHTML, созданные через политику:
// ХОРОШО: Trusted Types policy
if (window.trustedTypes && window.trustedTypes.createPolicy) {
const policy = window.trustedTypes.createPolicy("default", {
createHTML: (input) => DOMPurify.sanitize(input),
});
// теперь любая попытка element.innerHTML = userInput пройдёт через DOMPurify
}
Включается в CSP: require-trusted-types-for 'script'; trusted-types default dompurify.
Типичные ловушки:
eval,new Function(),setTimeout("code", ...)— запрещены безunsafe-eval. ИспользуйтеsetTimeout(() => {...}, ...).- Inline
onclick/onload— запрещены. Привязывайте черезaddEventListener. - Inline
<style>— нужен nonce или'unsafe-inline'(последнее убивает защиту от инъекций стилей). innerHTMLс пользовательским вводом — DOMPurify обязателен; с Trusted Types — на уровне браузера принудительно.- Сторонние виджеты (чат-боты, виджеты соцсетей) часто требуют
'unsafe-inline'и'unsafe-eval'— изучайте их CSP-требования до интеграции.
Дополнительные security-заголовки
CSP — главный, но не единственный. Минимальный набор для production:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Расшифровка:
- HSTS — браузер запоминает, что сайт работает только по HTTPS.
preload— предзагрузка в список Chromium/Firefox (заявка на hstspreload.org). nosniff— браузер не угадывает MIME-тип (защита от polyglot-файлов).X-Frame-Options: DENY— запрет встраивания в iframe (legacy, но всё ещё нужен;frame-ancestorsв CSP заменяет, но не все боты понимают).Referrer-Policy— управление передачейReferer.strict-origin-when-cross-origin— баланс приватности и аналитики.Permissions-Policy— отключаем неиспользуемые API.interest-cohort=()— отказ от Google FLoC.- COOP/COEP/CORP — изоляция процессов браузера, защита от Spectre-class и cross-origin утечек. Нужно для
SharedArrayBuffer(WASM-тяжёлые приложения).
Проверить все заголовки разом — securityheaders.com или observatory.mozilla.org. Цель — A+ на обоих.
Pen testing, bug bounty, ASVS
Чек-лист после внедрения базовой гигиены:
- OWASP ASVS (Application Security Verification Standard) — формальный список из 280+ требований с тремя уровнями (Level 1 — базовый, Level 2 — для бизнес-данных, Level 3 — для критичных систем). Используйте как чек-лист на ревью архитектуры.
- OWASP ZAP — open-source DAST-сканер. Запускайте против stage-окружения раз в неделю, fail на high. Есть GitHub Action
zaproxy/action-baseline. - Burp Suite (Community/Pro) — стандарт для ручного пентеста. Перехват запросов, фаззинг, повтор, intruder для брутфорса, repeater для ручной модификации.
- Bug bounty — публичная программа на HackerOne, Bugcrowd, Standoff365. Платите за подтверждённые находки. Окупается даже на средних проектах: один найденный SQLi дешевле, чем серия штрафов после утечки.
SAST и DAST в CI/CD
SAST (Static Application Security Testing) ищет уязвимости в исходниках без запуска. DAST — динамически, через запросы к работающему приложению.
SAST-инструменты:
- Semgrep — open-source, правила на YAML, есть готовый ruleset
p/owasp-top-ten. Скорость — десятки тысяч строк в секунду. - SonarQube / SonarCloud — коммерческий, глубокая интеграция с PR.
- CodeQL (GitHub) — бесплатно для open-source, мощный язык запросов, есть готовые packs для security.
- Snyk Code — фокус на DevSecOps, интеграция с IDE.
Минимальный шаг Semgrep в GitHub Actions:
- name: Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/owasp-top-ten
p/javascript
p/typescript
p/security-audit
DAST в CI — ZAP baseline scan против stage:
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.10.0
with:
target: "https://stage.example.com"
fail_action: true
Сценарий зрелого пайплайна: pre-commit (gitleaks для секретов, eslint-plugin-security) → PR (Semgrep + npm audit) → merge в main (Snyk SCA + контейнер scan через Trivy) → deploy на stage (ZAP baseline) → раз в неделю (полный ZAP scan + ручной обзор отчётов).
Итого
OWASP Top 10 — это не «10 кнопок, которые надо нажать», а карта рисков, по которой строится культура безопасной разработки. 90% инцидентов закрываются базовой гигиеной: HTTPS с HSTS, параметризованные запросы, RBAC с проверкой на сервере, rate-limit, актуальные зависимости через Dependabot, security-заголовки, структурированные логи. Оставшиеся 10% — это threat modeling на этапе дизайна, supply-chain hygiene (SBOM, signed commits, верификация артефактов), strict CSP с nonce, регулярные пентесты и bug bounty.
Главное правило: безопасность не добавляется в конце спринта, она проектируется с первого коммита. Каждый PR — повод спросить «а что если этот ID подменить, а если параметр конкатенируется в SQL, а если зависимость с уязвимостью пробралась в lock-файл». Это дешевле и эффективнее, чем postmortem после инцидента.
Частые вопросы
Какая самая частая уязвимость веб-приложений по OWASP Top 10?
A01 — Broken Access Control. Лидер с 2021 года, подтверждается данными OWASP по 94% протестированных приложений. Самый частый подвид — IDOR: пользователь меняет ID в URL и получает чужие данные. Лечится middleware с обязательной проверкой принадлежности объекта пользователю на сервере, никогда не на клиенте. Дополнительно — UUID/ULID вместо инкрементальных ID, единый policy-движок (Casbin, OPA), интеграционные тесты на негативные сценарии. Никогда не доверяйте параметру userId из клиента — берите ID из сессии или JWT. Внутри API используйте RBAC или ABAC, не хардкодьте роли в коде фронта.
Как защититься от SQL-инъекций в Node.js и Go?
Универсальное правило — никогда не конкатенируйте пользовательский ввод в команды. В Node.js используйте параметризованные запросы через драйвер (pg, mysql2) или ORM (Prisma, Drizzle). В Go — database/sql с placeholder-ами ($1, $2 для Postgres, ? для MySQL). Современные ORM делают параметризацию автоматически и закрывают 99% инъекций. Если приходится писать raw SQL для оптимизации — обязательно через prepared statement с явными параметрами. Дополнительно: Semgrep с правилом javascript.lang.security.audit.sqli ловит конкатенации в CI до мержа.
Как настроить strict Content Security Policy в Next.js 15?
Через middleware.ts: генерируйте nonce на каждый запрос (Buffer.from(crypto.randomUUID()).toString base64), передавайте его в заголовке Content-Security-Policy и в request headers, в layout.tsx читайте через headers().get x-nonce и прокидывайте в Script. Используйте strict-dynamic, чтобы скрипт с nonce мог загружать дочерние скрипты. Сначала катите в Report-Only режиме на 1-2 недели, собирайте нарушения через report-uri в Sentry, чините легитимные, потом переключайте на enforce. Не используйте unsafe-inline и unsafe-eval — они сводят защиту от XSS к нулю.
Какие security-заголовки нужно настроить в HTTP-ответах?
Минимальный набор: Strict-Transport-Security max-age 63072000 includeSubDomains preload, X-Content-Type-Options nosniff, X-Frame-Options DENY (плюс frame-ancestors none в CSP), Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy с пустыми скобками для неиспользуемых API (camera, microphone, geolocation, interest-cohort), Cross-Origin-Opener-Policy same-origin, Cross-Origin-Resource-Policy same-origin. Главный заголовок — Content-Security-Policy со строгим script-src через nonce. Проверка через securityheaders.com и observatory.mozilla.org, цель A+ на обоих.
Что такое SSRF и как защититься в Node.js?
Server-Side Request Forgery — сервер по запросу пользователя ходит на произвольный URL, и атакующий заставляет его лезть во внутреннюю сеть или к metadata облака (169.254.169.254 на AWS, Yandex Cloud, GCP отдают IAM-токены без auth). Защита: обёртка safeFetch с allowlist протоколов (только http и https), резолвом DNS и проверкой всех IP против списка приватных подсетей (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1/128, fc00::/7), таймаут 5 секунд, redirect manual. Дополнительно — выделенный egress-proxy, IMDSv2 на AWS, network policies в k8s, запрет egress в metadata-подсети.
Как защитить аутентификацию от перебора и credential stuffing?
Многослойная защита. Rate-limit на /login через express-rate-limit с RedisStore: 10 попыток на IP и 5 на email за 15 минут. Блокировка аккаунта после N провалов с email-уведомлением владельцу. Обязательный MFA для админских ролей (TOTP через otplib или WebAuthn). Проверка пароля при регистрации против списка топ-10000 утёкших паролей или API haveibeenpwned. Пароли хэшируются argon2id (memoryCost 19456, timeCost 2) или bcrypt — никогда не SHA или MD5. JWT с явным указанием algorithms HS256 (защита от alg none атаки), коротким TTL 15 минут плюс refresh-токен с ротацией. Куки HttpOnly, Secure, SameSite Lax или Strict, префикс __Host- для критичных.
Какой реальный пример supply-chain атаки в 2024 году?
CVE-2024-3094 — backdoor в xz utils. Злоумышленник под именем Jia Tan два года втирался в доверие как мейнтейнер библиотеки xz/liblzma, которая транзитивно подтягивается в OpenSSH через systemd на большинстве дистрибутивов Linux. В версиях 5.6.0 и 5.6.1 он встроил бэкдор: специально сформированные тестовые файлы при сборке через autotools модифицировали shared object и добавляли проверку публичного ключа атакующего в SSH-аутентификацию. Случайно обнаружен инженером Microsoft Andres Freund по 500-мс задержке логина. Уроки: SBOM обязателен, reproducible builds желательны, signed commits с GPG/Sigstore, не доверяйте слепо транзитивным зависимостям, мониторинг аномалий производительности после обновлений критичных библиотек.