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

Говоря о веб-безопасности, обычно вспоминают SQL-инъекции или XSS. Но в отчётах экспертизы регулярно встречается ещё одна категория — уязвимости бизнес-логики. Автоматические сканеры их почти не находят: технически код работает «правильно». Проблема в логике — система доверяет тому, чему доверять не должна.
Один из самых наглядных примеров — процесс оплаты. В этой статье разберём случай, найденный при экспертизе нашего собственного продукта (SaaS-платформы для налогового и финансового учёта), и то, как мы его закрыли.
Как выглядит проблема
Типичный процесс оплаты:
- Пользователь выбирает тариф и нажимает «Оплатить».
- Сайт перенаправляет его на страницу платёжного провайдера.
- Пользователь вводит данные карты.
- Провайдер возвращает пользователя на сайт (return URL).
- Провайдер отдельным серверным запросом (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 агент быстро анализирует код, связанный с оплатой, регистрацией и сменой статусов, а специалист проверяет каждую находку на практике и исправляет её.
Если вы готовите проект к официальной экспертизе, оставьте заявку на сканирование. Мы поможем найти и закрыть уязвимости в оплате и других ключевых сценариях ещё до экспертизы.


