О насБлогКонтакты
Безопасность18 апреля 2026 г. 7 мин 3

OWASP Top 10 2025: безопасность веб-приложений — реальные атаки и защита

AunimedaAunimeda
📋 Содержание

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 сравнение

Читайте также

Node.js vs Bun vs Deno 2026: какой JavaScript runtime выбратьaunimeda
Разработка

Node.js vs Bun vs Deno 2026: какой JavaScript runtime выбрать

Bun 1.x стабилен в production. Deno 2.0 поддерживает npm. Node.js 22 запускает TypeScript нативно. Реальные бенчмарки, сравнение экосистем и конкретные рекомендации — для новых и существующих проектов.

State of JavaScript 2026: что изменилось и куда движется экосистемаaunimeda
Разработка

State of JavaScript 2026: что изменилось и куда движется экосистема

Vite обошёл webpack. TypeScript — дефолт для новых проектов. React сохраняет доминирование, но Signal-based фреймворки растут. AI-assisted coding меняет что значит 'написать код'. Честный разбор состояния JavaScript-экосистемы в 2026.

Чистая архитектура в Node.js: практическое руководство без академизмаaunimeda
Разработка

Чистая архитектура в Node.js: практическое руководство без академизма

Чистая архитектура звучит хорошо в теории. На практике большинство реализаций добавляют сложность без пользы. Показываем паттерн, который реально работает в production Node.js TypeScript проектах — инверсия зависимостей, use cases, repository pattern с рабочим кодом.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в России. Бесплатная консультация.

Получить консультацию Все статьи