OWASP Top 10 2025: безопасность веб-приложений — реальные атаки и защита
OWASP Top 10 — это не абстрактный список из методичек. Это конкретные классы уязвимостей, которые стоят за большинством утечек данных. Каждый разработчик, пишущий веб-приложения, обязан понимать эти паттерны — не как теорию, а как ошибки в собственном коде.
A01: Сломанный контроль доступа
Первая строчка уже четвёртый год подряд. Суть: пользователь получает доступ к чужим ресурсам.
Атака IDOR (Insecure Direct Object Reference):
GET /api/orders/5678 ← заказ другого пользователя
Если API проверяет только аутентификацию (залогинен ли пользователь), но не авторизацию (принадлежит ли ему ресурс 5678) — это уязвимость.
Уязвимый код:
// ❌ Проверяем только что пользователь залогинен
router.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.order.findUnique({ where: { id: req.params.orderId } });
res.json(order); // любой авторизованный пользователь получит любой заказ
});
Исправление:
// ✅ Скоупируем запрос на текущего пользователя
router.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.order.findUnique({
where: {
id: req.params.orderId,
userId: req.user.id, // пользователь видит только свои заказы
},
});
if (!order) return res.status(404).json({ error: 'Не найдено' });
res.json(order);
});
Для административных эндпоинтов — явная проверка роли:
function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user.roles.includes(role)) {
return res.status(403).json({ error: 'Доступ запрещён' });
}
next();
};
}
router.delete('/api/admin/users/:id', authenticate, requireRole('admin'), deleteUser);
A02: Криптографические сбои
Неправильное хранение паролей и чувствительных данных.
Опасные практики:
// ❌ MD5 — не алгоритм хэширования паролей
const hash = crypto.createHash('md5').update(password).digest('hex');
// ❌ SHA1 — не лучше
const hash = crypto.createHash('sha1').update(password).digest('hex');
// ❌ Предсказуемые токены
const resetToken = Math.random().toString(36); // брутфорсится
Правильный подход:
import bcrypt from 'bcrypt';
import crypto from 'crypto';
// ✅ bcrypt: намеренно медленный (factor 12 = ~250ms на хэш)
const SALT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// ✅ Криптографически безопасный токен
function generateSecureToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString('hex');
}
Для JWT — используйте короткий срок жизни и асимметричные ключи:
import { SignJWT, jwtVerify, generateKeyPair } from 'jose';
// RS256: подпись приватным ключом, проверка публичным
const { privateKey, publicKey } = await generateKeyPair('RS256');
const token = await new SignJWT({ userId, role })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('15m') // короткий срок — 15 минут
.sign(privateKey);
A03: Инъекции
SQL, NoSQL, команды ОС — что угодно, где пользовательский ввод попадает в интерпретатор.
SQL-инъекция:
// ❌ Классическая уязвимость
const users = await db.query(
`SELECT * FROM users WHERE email = '${req.body.email}'`
);
// Ввод: admin'-- → входим без пароля
// Ввод: '; DROP TABLE users;-- → удаляем базу
Параметризованные запросы:
// ✅ Ввод никогда не интерпретируется как SQL
const users = await db.query(
'SELECT * FROM users WHERE email = $1',
[req.body.email]
);
// ✅ ORM (Prisma, Drizzle) — параметризация по умолчанию
const user = await prisma.user.findUnique({
where: { email: req.body.email },
});
NoSQL-инъекция (MongoDB):
// ❌ Уязвимо
const user = await User.findOne({ email: req.body.email });
// Атака: { "email": {"$gt": ""}, "password": {"$gt": ""} }
// Результат: возвращает первого пользователя → обход авторизации
// ✅ Валидация входных данных
import { z } from 'zod';
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const { email, password } = LoginSchema.parse(req.body);
// email гарантированно строка → инъекция невозможна
A04: Небезопасный дизайн
Архитектурные уязвимости — не ошибки реализации, а просчёты проектирования.
Пример: предсказуемый токен сброса пароля:
// ❌ Токен на основе времени
function generateResetToken(userId: string): string {
return Buffer.from(`${userId}:${Date.now()}`).toString('base64');
}
// Атакующий знает userId → брутфорс диапазона времени
// ✅ Безопасный дизайн
async function createPasswordResetToken(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString('hex');
const hash = crypto.createHash('sha256').update(token).digest('hex');
await db.passwordReset.create({
data: {
userId,
tokenHash: hash, // в БД храним хэш, не сам токен
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 минут
usedAt: null,
},
});
return token; // отправляем пользователю
}
A05: Некорректная конфигурация безопасности
Отсутствие заголовков безопасности, verbose-ошибки в production, дефолтные учётные данные.
Обязательные HTTP-заголовки:
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
hsts: {
maxAge: 63072000, // 2 года
includeSubDomains: true,
preload: true,
},
}));
Никаких stack trace в production:
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error({ err, url: req.url, userId: req.user?.id });
res.status(500).json(
process.env.NODE_ENV === 'production'
? { error: 'Внутренняя ошибка сервера' }
: { error: err.message, stack: err.stack }
);
});
A06: Устаревшие компоненты с уязвимостями
# Аудит зависимостей
npm audit
# Автоматическое исправление
npm audit fix
# В CI — блокировать при критических уязвимостях
npx audit-ci --high
Интеграция в GitHub Actions:
- name: Security audit
run: npm audit --audit-level=moderate
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
severity: 'HIGH,CRITICAL'
exit-code: '1'
A07: Сбои идентификации и аутентификации
Брутфорс, перебор учётных записей, ненадёжное управление сессиями.
Rate limiting на auth-эндпоинтах:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 10, // 10 попыток с одного IP
store: new RedisStore({ client: redis }),
handler: (req, res) => {
res.status(429).json({
error: 'Слишком много попыток. Попробуйте через 15 минут.',
});
},
});
app.post('/api/auth/login', loginLimiter, loginHandler);
Не раскрывайте существование аккаунта:
// ❌ Раскрывает факт наличия email в базе
if (!user) return res.status(404).json({ error: 'Пользователь не найден' });
if (!validPassword) return res.status(401).json({ error: 'Неверный пароль' });
// ✅ Одинаковое сообщение при любом исходе
if (!user || !await verifyPassword(password, user.passwordHash)) {
return res.status(401).json({ error: 'Неверный email или пароль' });
}
A08: Нарушение целостности данных и ПО
Небезопасная десериализация, загрязнение прототипов.
// ❌ Загрязнение прототипа через merge
function merge(target: any, source: any) {
for (const key in source) {
if (typeof source[key] === 'object') {
target[key] ??= {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// Атака: { "__proto__": { "isAdmin": true } } → все объекты становятся admin
// ✅ Безопасный merge
import { merge } from 'lodash'; // lodash защищён от этого
// или
const safe = JSON.parse(JSON.stringify(userInput)); // очищает прототип
A09: Недостаточное логирование и мониторинг
const securityLogger = {
authFailure: (email: string, ip: string, reason: string) => {
logger.warn({
event: 'AUTH_FAILURE',
email, ip, reason,
timestamp: new Date().toISOString(),
});
},
accessDenied: (userId: string, resource: string) => {
logger.warn({
event: 'ACCESS_DENIED',
userId, resource,
timestamp: new Date().toISOString(),
});
},
};
Настройте алерты: 5+ неудачных входов с одного IP за минуту → немедленное уведомление.
A10: Server-Side Request Forgery (SSRF)
Атакующий заставляет сервер делать запросы к внутренним сервисам.
// ❌ Уязвимо
app.post('/api/preview', async (req, res) => {
const response = await fetch(req.body.url);
// Атака: url = 'http://169.254.169.254/latest/meta-data/'
// Результат: утечка AWS IAM credentials
});
// ✅ Валидация URL с проверкой приватных диапазонов
import dns from 'dns/promises';
import ipRangeCheck from 'ip-range-check';
const BLOCKED = ['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'];
async function isSafeUrl(urlString: string): Promise<boolean> {
try {
const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) return false;
const ips = await dns.resolve4(url.hostname);
return ips.every(ip => !ipRangeCheck(ip, BLOCKED));
} catch {
return false;
}
}
Минимальный чеклист безопасности
[ ] Весь пользовательский ввод валидируется (Zod/Joi)
[ ] Только параметризованные SQL-запросы
[ ] Пароли: bcrypt/argon2 с cost factor 10+
[ ] Заголовки безопасности (Helmet)
[ ] Rate limiting на auth-эндпоинтах
[ ] JWT с коротким сроком жизни + ротация refresh-токенов
[ ] npm audit в CI
[ ] Нет stack trace в production
[ ] Каждый запрос к ресурсу скоупирован на пользователя
[ ] HTTPS + HSTS
[ ] Логирование событий безопасности
Aunimeda строит защищённые production-приложения. Обсудим архитектуру.
Смотрите также: TypeScript продвинутые типы, Node.js vs Bun сравнение