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

Безопасность веб-приложений: как мы нашли и устранили 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, но при этом, конечно, добавили и свои проверки, которые особенно важны для финтеха. Это и бизнес-логика транзакций, и двухфакторная аутентификация, и, разумеется, соответствие 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), мы обнаружили, что местами разработчики, стремясь к «оптимизации» сложных выборок, зачем-то писали сырые 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 уязвимостей, я считаю, что это совсем не приговор. Для системы, которая целых три года развивалась без выделенного security-инженера, это вполне себе нормальный результат. Ведь главное, на наш взгляд, не столько то, сколько всего нашли, сколько то, как быстро это закрыли и что конкретно сделали, чтобы такое больше не повторялось.

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

Кстати, если вам нужна помощь с архитектурой, DevOps, безопасностью или разработкой, то специалисты АйТи Фреш всегда готовы помочь. У нас за плечами 15+ лет опыта!

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

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

загрузка...

Подпишитесь на рассылку ITfresh

Раз в неделю — практические гайды для руководителя IT и сисадмина: безопасность, 1С, миграции, резервные копии, лайфхаки из реальных проектов.

Реквизиты оператора персональных данных

ООО «АЙТИ-ФРЕШ», ИНН 7719418495, КПП 771901001. Юридический адрес: 105523, г. Москва, Щёлковское шоссе, д. 92, корп. 7. Контакт: info@itfresh.ru, +7 903 729-62-41. Оператор обрабатывает e-mail подписчика в целях рассылки информационных и рекламных материалов до момента отзыва согласия.