Перейти к основному содержанию
Блог

Веб

Страница «оплата прошла» до подтверждения платежа: уязвимость бизнес-логики

Показывать «оплата успешна», как только пользователь вернулся от платёжного провайдера, — ошибка. Статус подтверждает только callback провайдера.

· 7 мин. чтения

Фото: Shixart1985, CC BY 2.0 · Wikimedia Commons

Говоря о веб-безопасности, обычно вспоминают SQL-инъекции или XSS. Но в отчётах экспертизы регулярно встречается ещё одна категория — уязвимости бизнес-логики. Автоматические сканеры их почти не находят: технически код работает «правильно». Проблема в логике — система доверяет тому, чему доверять не должна.

Один из самых наглядных примеров — процесс оплаты. В этой статье разберём случай, найденный при экспертизе нашего собственного продукта (SaaS-платформы для налогового и финансового учёта), и то, как мы его закрыли.

Как выглядит проблема

Типичный процесс оплаты:

  1. Пользователь выбирает тариф и нажимает «Оплатить».
  2. Сайт перенаправляет его на страницу платёжного провайдера.
  3. Пользователь вводит данные карты.
  4. Провайдер возвращает пользователя на сайт (return URL).
  5. Провайдер отдельным серверным запросом (callback или webhook) сообщает результат платежа.

Ошибка возникает на шаге 4. Во многих проектах при открытии return URL — например, /payment/success?order=123 — сразу показывается «Оплата прошла успешно», а иногда ещё и активируется подписка. Но попасть на этот адрес можно и без оплаты: достаточно ввести его вручную, обновить страницу или нажать «назад» на странице провайдера.

В нашем случае эксперты показали именно это: страница успеха отображалась до того, как провайдер действительно подтверждал платёж. Даже если деньги не были списаны, пользователь видел в интерфейсе «оплачено».

Почему это серьёзно

  • Финансовые потери. Если страница успеха ещё и включает услугу, пользователь получает платные функции без оплаты.
  • Неверная отчётность. В бухгалтерии и админ-панели появляются заказы, помеченные как оплаченные, хотя оплаты не было.
  • Доверие. Пользователь видит «оплачено», а потом услуга отключается — это лишние обращения в поддержку.

OWASP относит такие ошибки к категории A04 «Insecure Design» в Top 10 (2021). Близкое описание в CWE — CWE-840 (Business Logic Errors).

Главное правило: любые данные, пришедшие через браузер пользователя, — не подтверждение, а лишь сигнал. Статус платежа меняет только подписанное серверное сообщение провайдера.

Правильная архитектура

1. Чёткие статусы заказа

Создавайте отдельную запись для каждого платежа и явно задавайте её статус: pending, paid, failed, expired. Новый заказ всегда создаётся в статусе pending.

2. Статус меняет только callback

Когда приходит callback провайдера:

  • проверьте подпись или секретный ключ (у каждого провайдера свой механизм);
  • убедитесь, что сумма и валюта совпадают с заказом;
  • если заказ уже paid, не обрабатывайте его повторно (идемпотентность);
  • только после этого переводите статус в paid и активируйте услугу.

Упрощённый пример на Laravel:

public function callback(Request $request)
{
    abort_unless($this->provider->verifySignature($request), 403);

    $payment = Payment::where('external_id', $request->input('transaction_id'))
        ->lockForUpdate()->firstOrFail();

    if ($payment->status === 'paid') {
        return response()->json(['ok' => true]); // повторный callback
    }

    abort_unless((int) $request->input('amount') === $payment->amount, 422);

    DB::transaction(function () use ($payment) {
        $payment->update(['status' => 'paid', 'paid_at' => now()]);
        $payment->subscription->activate();
    });

    return response()->json(['ok' => true]);
}

3. Страница возврата только показывает статус

Когда пользователь возвращается, страница ничего не меняет. Она читает статус из базы и показывает соответствующее сообщение:

  • paid — «Платёж подтверждён»;
  • pending — «Платёж проверяется» и небольшой запрос, который через несколько секунд снова спрашивает статус;
  • failed или expired — «Платёж не прошёл» и кнопка повторной попытки.

Кроме того, страница возврата должна открываться только владельцу заказа. Иначе, подставив ?order=124, можно увидеть чужой заказ — а это уже отдельная уязвимость (IDOR).

4. Очищайте устаревшие pending-платежи

Callback может не прийти вовсе: пользователь закрыл страницу провайдера или пропала связь. Такие записи не должны навсегда оставаться в pending. Мы добавили плановую задачу: платежи, не подтверждённые за определённое время, переводятся в expired, при необходимости итоговый статус запрашивается через API провайдера.

// routes/console.php
Schedule::call(function () {
    Payment::where('status', 'pending')
        ->where('created_at', '<', now()->subHours(2))
        ->update(['status' => 'expired']);
})->hourly();

Интервал выбирайте с учётом условий вашего провайдера.

Чек-лист

Проверьте в своём проекте:

  • Если открыть return URL вручную, активируется ли услуга или появляется ли «оплачено»?
  • Проверяется ли подпись callback? Отправьте запрос без подписи — он должен быть отклонён.
  • Сравнивается ли сумма в callback с суммой заказа?
  • Если один и тот же callback придёт дважды, продлится ли подписка дважды?
  • Сколько старых pending-записей и кто их очищает?
  • Показывает ли страница возврата заказ другого пользователя?

Другие ошибки бизнес-логики

Оплата — самый частый, но не единственный пример. В отчёте нашей экспертизы была ещё одна находка из области бизнес-логики: некоторые действия можно было выполнить без проверки текущего состояния объекта. Например, редактировать уже закрытую запись или пропустить этап согласования. Решение то же: перед каждым действием на сервере должна быть проверка «разрешено ли это действие в этом состоянии?». Скрытая в интерфейсе кнопка — не защита.

Итог

Уязвимости бизнес-логики требуют чтения кода и понимания процесса целиком. Поэтому мы ищем их не автоматическим сканером, а совместным разбором каждого сценария: Claude AI агент быстро анализирует код, связанный с оплатой, регистрацией и сменой статусов, а специалист проверяет каждую находку на практике и исправляет её.

Если вы готовите проект к официальной экспертизе, оставьте заявку на сканирование. Мы поможем найти и закрыть уязвимости в оплате и других ключевых сценариях ещё до экспертизы.