Безопасность веб-приложений: как мы нашли и устранили 47 уязвимостей

Масштаб аудита

Система «БанкОнлайн Плюс» — это цифровой банкинг: личный кабинет клиента, мобильное API, внутренняя CRM для операторов и административная панель. Технологический стек:

  • Backend: Node.js (Express) + Python (FastAPI) для ML-скоринга
  • Frontend: React SPA
  • База данных: PostgreSQL + Redis
  • Инфраструктура: Kubernetes на AWS EKS

Всего — 340 API-эндпоинтов, 89 React-компонентов с формами, 12 микросервисов. Команда аудита: 3 специалиста по безопасности, 4 недели работы.

Методология: OWASP Top 10 как фреймворк

Мы структурировали аудит по категориям OWASP Top 10 2021, добавив специфичные для финтеха проверки: бизнес-логика транзакций, двухфакторная аутентификация, compliance с PCI DSS.

Инструментарий:

  • SAST: SonarQube, Semgrep, CodeQL
  • DAST: OWASP ZAP, Burp Suite Pro
  • Зависимости: npm audit, Snyk, safety (Python)
  • Ручное тестирование: Burp Suite для перехвата и модификации запросов

A03:2021 — Injection: 8 уязвимостей

Самые опасные находки. Несмотря на использование ORM (Sequelize), в нескольких местах разработчики использовали raw SQL-запросы для «оптимизации» сложных выборок.

SQL-инъекция в поиске транзакций

Эндпоинт /api/transactions/search принимал параметр сортировки напрямую:

// УЯЗВИМЫЙ КОД — так было
app.get('/api/transactions/search', auth, async (req, res) => {
  const { query, sortBy, order } = req.query;

  // sortBy и order подставляются напрямую в SQL!
  const results = await db.query(`
    SELECT * FROM transactions
    WHERE description ILIKE '%${query}%'
    AND user_id = ${req.user.id}
    ORDER BY ${sortBy} ${order}
    LIMIT 50
  `);

  res.json(results.rows);
});

Через параметр sortBy мы смогли извлечь список таблиц базы:

# PoC: извлечение имён таблиц
GET /api/transactions/search?query=test&sortBy=(CASE+WHEN+(SELECT+COUNT(*)+FROM+information_schema.tables+WHERE+table_name='users')>0+THEN+created_at+ELSE+amount+END)&order=DESC

Исправление — параметризация и whitelist:

// ИСПРАВЛЕННЫЙ КОД
const ALLOWED_SORT_FIELDS = ['created_at', 'amount', 'status'];
const ALLOWED_ORDER = ['ASC', 'DESC'];

app.get('/api/transactions/search', auth, async (req, res) => {
  const { query, sortBy, order } = req.query;

  if (!ALLOWED_SORT_FIELDS.includes(sortBy)) {
    return res.status(400).json({ error: 'Invalid sort field' });
  }
  if (!ALLOWED_ORDER.includes(order?.toUpperCase())) {
    return res.status(400).json({ error: 'Invalid order' });
  }

  const results = await db.query(
    `SELECT * FROM transactions
     WHERE description ILIKE $1
     AND user_id = $2
     ORDER BY ${sortBy} ${order.toUpperCase()}
     LIMIT 50`,
    [`%${query}%`, req.user.id]
  );

  res.json(results.rows);
});

A07:2021 — Cross-Site Scripting: 12 уязвимостей

React по умолчанию экранирует вывод, но мы нашли 12 мест с dangerouslySetInnerHTML — в основном в блоге и справочном разделе, где контент приходил из CMS.

// УЯЗВИМЫЙ КОД
function ArticleContent({ html }) {
  // Контент из CMS подставляется без санитизации
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

// ИСПРАВЛЕННЫЙ КОД
import DOMPurify from 'dompurify';

function ArticleContent({ html }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'b', 'i', 'a', 'ul', 'ol', 'li', 'h2', 'h3',
                   'code', 'pre', 'blockquote', 'img'],
    ALLOWED_ATTR: ['href', 'src', 'alt', 'class'],
    ALLOW_DATA_ATTR: false,
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Также нашли Stored XSS в чате поддержки: оператор мог вставить HTML в ответ клиенту, который рендерился без экранирования в мобильном WebView.

A01:2021 — Broken Access Control: 11 уязвимостей

Самая массовая категория. Основные паттерны:

IDOR в API документов

Эндпоинт /api/documents/:id проверял аутентификацию, но не проверял, принадлежит ли документ текущему пользователю:

// УЯЗВИМЫЙ КОД
app.get('/api/documents/:id', auth, async (req, res) => {
  const doc = await Document.findByPk(req.params.id);
  if (!doc) return res.status(404).json({ error: 'Not found' });
  res.json(doc); // Любой авторизованный юзер видит любой документ
});

// ИСПРАВЛЕННЫЙ КОД
app.get('/api/documents/:id', auth, async (req, res) => {
  const doc = await Document.findOne({
    where: {
      id: req.params.id,
      userId: req.user.id, // Проверка владельца
    },
  });
  if (!doc) return res.status(404).json({ error: 'Not found' });
  res.json(doc);
});

Мы нашли 7 эндпоинтов с аналогичной проблемой: документы, выписки, шаблоны переводов, настройки уведомлений. Для системного исправления внедрили middleware авторизации на уровне ресурса:

// middleware/ownership.js
function ownershipCheck(model, ownerField = 'userId') {
  return async (req, res, next) => {
    const resource = await model.findByPk(req.params.id);
    if (!resource) {
      return res.status(404).json({ error: 'Not found' });
    }
    if (resource[ownerField] !== req.user.id) {
      // Логируем попытку доступа к чужому ресурсу
      logger.warn('IDOR attempt', {
        userId: req.user.id,
        resourceId: req.params.id,
        model: model.name,
        ip: req.ip,
      });
      return res.status(404).json({ error: 'Not found' });
    }
    req.resource = resource;
    next();
  };
}

// Использование
app.get('/api/documents/:id', auth, ownershipCheck(Document), (req, res) => {
  res.json(req.resource);
});

A07:2021 — Broken Authentication: 5 уязвимостей

Критичные находки:

  • JWT без expiration — токены жили бесконечно. Установили TTL 15 минут + refresh token на 7 дней
  • Отсутствие блокировки аккаунта — брутфорс пароля ничем не ограничивался. Внедрили прогрессивную задержку: 1с, 2с, 4с, 8с... после 5 попыток — блокировка на 30 минут
  • 2FA обходилась — после ввода пароля API возвращал токен ещё до проверки OTP-кода. Разделили на два этапа с промежуточным session token

Security Headers: мисконфигурация

Сервер отдавал ответы без критичных заголовков безопасности. Мы настроили Helmet.js:

// security-headers.js
const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'strict-dynamic'"],
      styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com"],
      imgSrc: ["'self'", "data:", "cdn.bankonline.example"],
      fontSrc: ["'self'", "fonts.gstatic.com"],
      connectSrc: ["'self'", "api.bankonline.example"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  crossOriginEmbedderPolicy: true,
  crossOriginOpenerPolicy: { policy: 'same-origin' },
  crossOriginResourcePolicy: { policy: 'same-origin' },
}));

// Дополнительные заголовки
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy',
    'camera=(), microphone=(), geolocation=(self), payment=(self)');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  next();
});

Rate Limiting: полное отсутствие

Ни один эндпоинт не имел ограничения по частоте запросов. Мы внедрили многоуровневый rate limiting:

// rate-limiter.js
const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

// Глобальный лимит: 100 req/min на IP
const globalLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl_global',
  points: 100,
  duration: 60,
});

// Строгий лимит для аутентификации: 5 попыток за 15 мин
const authLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl_auth',
  points: 5,
  duration: 900,
  blockDuration: 1800, // Блокировка на 30 мин
});

// Лимит для финансовых операций: 10 переводов в минуту
const transactionLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl_transaction',
  points: 10,
  duration: 60,
});

function createRateLimitMiddleware(limiter, keyFn) {
  return async (req, res, next) => {
    try {
      const key = keyFn(req);
      await limiter.consume(key);
      next();
    } catch (rejRes) {
      const retryAfter = Math.ceil(rejRes.msBeforeNext / 1000);
      res.set('Retry-After', retryAfter);
      res.status(429).json({
        error: 'Too many requests',
        retryAfter,
      });
    }
  };
}

Уязвимости зависимостей

Запуск npm audit выявил 23 уязвимости, из них 4 критических. Самая опасная — prototype pollution в устаревшей версии lodash (4.17.15), использовавшейся через транзитивную зависимость.

# До исправления
$ npm audit
found 23 vulnerabilities (4 critical, 7 high, 8 moderate, 4 low)

# Автоматическое исправление
$ npm audit fix

# Для критических — ручное обновление
$ npm install lodash@4.17.21
$ npm install jsonwebtoken@9.0.2  # CVE-2022-23529

# Python-зависимости
$ pip install safety
$ safety check
+============================================+
| 3 vulnerabilities found                     |
| pyjwt<2.8.0    — CVE-2022-29217 — HIGH    |
| requests<2.32.0 — CVE-2024-35195 — MEDIUM |
| pillow<10.3.0   — CVE-2024-28219 — HIGH   |
+============================================+

Матрица приоритизации

Все 47 уязвимостей мы разложили по матрице «критичность / сложность исправления»:

ПриоритетКоличествоСрок исправленияПримеры
P0 (Critical)824 часаSQL-инъекции, обход 2FA, IDOR в финансовых операциях
P1 (High)141 неделяXSS, отсутствие rate limiting, JWT без expiry
P2 (Medium)162 неделиSecurity headers, зависимости, verbose error messages
P3 (Low)91 месяцCookie flags, информационные утечки в заголовках

Security Pipeline: автоматизация

Чтобы уязвимости не накапливались снова, мы встроили проверки безопасности в CI/CD:

# .github/workflows/security.yml
name: Security Pipeline
on:
  pull_request:
    branches: [main, develop]
  schedule:
    - cron: '0 3 * * 1' # Каждый понедельник в 3:00

jobs:
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: SonarQube Scan
        uses: SonarSource/sonarqube-scan-action@v2
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ vars.SONAR_URL }}
      - name: Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/nodejs
            p/react
            p/sql-injection

  dependency-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm audit --audit-level=high
      - run: npx snyk test --severity-threshold=high
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

  dast:
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule'
    services:
      app:
        image: ${{ vars.APP_IMAGE }}
        ports: ['3000:3000']
    steps:
      - name: OWASP ZAP Full Scan
        uses: zaproxy/action-full-scan@v0.10.0
        with:
          target: 'http://app:3000'
          rules_file_name: 'zap-rules.tsv'
          cmd_options: '-a -j -l WARN'
      - uses: actions/upload-artifact@v4
        with:
          name: zap-report
          path: report_html.html

Результаты после исправлений

Через 3 недели все P0 и P1 уязвимости были закрыты. Через 6 недель — все 47. Повторный аудит через 2 месяца нашёл только 2 новые уязвимости уровня P3 — обе в свежедобавленном функционале.

Ключевые метрики улучшения:

  • Security headers: оценка на securityheaders.com — с F до A+
  • SSL Labs: с B до A+
  • Среднее время обнаружения уязвимости: с «никогда» до 4 часов (в CI)
  • Время реагирования на P0: регламентировано 4 часа

Выводы для финтех-проектов

Безопасность — это не чеклист, который можно пройти один раз. Это процесс. Вот что мы рекомендуем каждому проекту, работающему с финансовыми данными:

  1. Внедрите SAST в CI с первого дня — SonarQube и Semgrep бесплатны для open-source
  2. Используйте параметризованные запросы без исключений — никаких raw SQL, даже «временно»
  3. Проверяйте авторизацию на уровне каждого ресурса — аутентификация != авторизация
  4. Rate limiting — на каждый публичный эндпоинт, с отдельными лимитами для критичных операций
  5. Регулярный DAST (раз в неделю минимум) ловит то, что SAST пропускает
  6. Зависимости — обновляйте агрессивно, используйте Dependabot или Renovate

47 уязвимостей — это не приговор. Это нормальный результат для системы, которая развивалась 3 года без выделенного security-инженера. Важно не сколько нашли, а как быстро закрыли и как предотвратили повторение.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#a012021#a032021#a072021#access#authentication#backend:#broken#control
Комментарии 0

Оставить комментарий

загрузка...