Вебхук ЮKassa доверял данным клиента: оплату брони можно было подделать одним запросом
Во время технического аудита сервиса аренды автомобилей обнаружилась критическая ошибка в обработчике уведомлений ЮKassa. Эндпоинт `/payments/webhook/yukassa` отмечал бронь оплаченной, ориентируясь на значение `event` во входящем JSON. Проблема заключалась в том, что это поле поступало из HTTP-запроса и никак не подтверждалось платёжным шлюзом.
В результате злоумышленнику было достаточно узнать идентификатор платежа и отправить на сервер запрос с событием `payment.succeeded`. После этого бронь могла перейти в статус "оплачено", даже если реального платежа не существовало.
Как работала уязвимая логика
До исправления обработчик выполнял лишь базовую проверку: искал в базе запись с переданным `gateway_payment_id`. Если такой платёж существовал, дальнейшее решение принималось на основании тела запроса.
Упрощённо логика выглядела так:
```python
if payment and payload["event"] == "payment.succeeded":
payment.status = "paid"
```
Для атаки не требовались авторизационные заголовки, секретный ключ или сложная подготовка. Идентификатор платежа не являлся конфиденциальным: сервис возвращал его клиенту сразу после создания платежа через `POST /bookings/{id}/pay`.
Таким образом, пользователь, который просто начал процесс оплаты, но не завершил его, уже обладал данными, достаточными для подделки уведомления. Оставалось отправить запрос с фиктивным событием успешной оплаты.
Почему одной подписи оказалось недостаточно
Первой идеей было добавить проверку подписи вебхука - подход, широко используемый платёжными системами. Однако в данном случае важно было учитывать особенности интеграции с ЮKassa.
ЮKassa поддерживает заголовок `Webhook-Signature`, формируемый с использованием HMAC-SHA256 и ключа вебхуков из личного кабинета. Но такой механизм является дополнительным, а не единственным способом проверки уведомлений.
В качестве основных вариантов обычно рассматриваются:
- проверка IP-адреса отправителя;
- самостоятельный запрос к API ЮKassa для получения актуального состояния платежа;
- дополнительная проверка подписи, если она настроена в конкретной интеграции.
На момент аудита `webhook_key` в проекте не использовался, поэтому сервер получал только тело запроса и сетевые данные. При этом инфраструктура работала за reverse proxy, а IP-адрес проходил через цепочку заголовков.
IP-фильтрацию решили не использовать как единственную защиту. Такой вариант потребовал бы постоянно поддерживать список адресов ЮKassa и доверять значениям вроде `X-Forwarded-For`. Иными словами, одна модель доверия просто сменилась бы другой, но проблема подтверждения самого платежа осталась бы.
Исправление: вебхук стал только сигналом для проверки
Надёжнее оказалось полностью изменить смысл входящего уведомления. Теперь тело вебхука не определяет состояние платежа, а лишь сообщает приложению, что необходимо обратиться к API ЮKassa.
После получения уведомления сервер:
1. извлекает идентификатор платежа;
2. запрашивает его состояние непосредственно у ЮKassa;
3. берёт статус только из ответа платёжного API;
4. сравнивает сумму и валюту с данными внутренней записи `Payment`;
5. меняет статус брони только при полном совпадении всех параметров.
Значение `payload["event"]` больше не используется как источник истины. Даже если атакующий отправит `payment.succeeded`, сервер проигнорирует его, если ЮKassa сообщает, что платёж находится в состоянии `pending`, `canceled` или имеет иной статус.
Отдельно проверяется сумма. Если шлюз подтверждает успешную оплату на 1 рубль, а в базе ожидается 2300 рублей, платёж не засчитывается. Несоответствие записывается в журнал, а полный ответ шлюза сохраняется в `gateway_response` для последующего анализа.
Что происходит при сбое API
При таймауте, сетевой ошибке или ответе 5xx от ЮKassa обработчик возвращает ошибку 5xx. Это важное решение: платёжная система сможет повторить доставку уведомления позже.
Такой подход называется fail-closed. Временная задержка при подтверждении оплаты предпочтительнее, чем автоматическое принятие неподтверждённого события. Сервер не должен превращать технический сбой внешнего API в успешную оплату.
Финальные статусы также защищены от повторной обработки. Если платёж уже имеет состояние `succeeded` или `failed`, повторное уведомление не переписывает его и не вызывает новый запрос к ЮKassa. Это снижает нагрузку и исключает неожиданные изменения результата из-за повторной доставки одного и того же события.
Тесты после исправления
Изменения в `payment_service.py` составили 62 добавленные и 5 удалённых строк. Дополнительно появился файл `tests/test_payment_webhook.py` объёмом около 205 строк.
Эквайер в тестах заменён mock-объектом. Проверяются следующие сценарии:
- фиктивный `payment.succeeded`, когда шлюз сообщает `pending`, не меняет статус брони;
- несовпадение суммы блокирует переход в оплаченный статус;
- подтверждённые статус, сумма и валюта переводят бронь в `paid`;
- подтверждённая отмена устанавливает `failed`;
- повторное уведомление по уже завершённому платежу не вызывает API;
- неизвестный `gateway_payment_id` игнорируется и также не передаётся во внешний запрос.
Локальный запуск тестов завершился успешно, статический анализатор `ruff` нарушений не обнаружил.
Почему старые тесты не нашли уязвимость
До аудита тесты проверяли только внутреннюю логику обработчика. Они формировали вход с `payment.succeeded` и ожидали, что бронь станет оплаченной. С точки зрения такого теста это было правильное поведение.
Но тесты не проверяли главный вопрос: кто подтвердил событие - клиент или платёжная система? Если тест самостоятельно создаёт доверенный вход, он не способен выявить отсутствие проверки источника.
Именно поэтому проблему обнаружили не автоматические проверки, а ручной анализ кода. Для платёжных интеграций необходимо тестировать не только позитивные сценарии, но и попытки подделки, рассинхронизацию суммы, неизвестные идентификаторы, повторные уведомления и сбои внешнего API.
Что сознательно не стали менять
В рамках исправления не добавили IP-allowlist ЮKassa. Такой механизм может использоваться как дополнительный слой защиты, но не должен заменять проверку платежа через API.
Также не стали включать обязательную проверку `Webhook-Signature`, поскольку текущая инфраструктура не была подготовлена к корректному управлению ключом вебхуков. Добавление заголовка без продуманного хранения секрета, ротации и обработки ошибок создало бы иллюзию безопасности.
Отдельно оставили прежнее поведение в девелоперском режиме. Если `YUKASSA_SHOP_ID` и `YUKASSA_SECRET_KEY` не заданы, приложение продолжает доверять тестовому телу запроса. Это допустимо только потому, что в таком режиме отсутствует реальный платёжный шлюз. Однако подобное поведение важно явно ограничивать окружением и не допускать его на production.
Практические выводы для вебхуков
Любое событие, пришедшее от клиента, следует считать неподтверждённым, даже если оно называется `payment.succeeded`. JSON вебхука - это данные запроса, а не доказательство факта оплаты.
Надёжная схема должна включать несколько независимых проверок:
- существование платежа в локальной базе;
- получение актуального состояния из API провайдера;
- совпадение суммы и валюты;
- соответствие платежа конкретной брони или заказу;
- защиту от повторной обработки;
- корректную реакцию на недоступность платёжного API;
- журналирование подозрительных расхождений.
Нельзя использовать один только идентификатор платежа как секрет. Если он возвращается клиенту, его следует считать публичным значением. Безопасность должна строиться не на сокрытии идентификатора, а на серверной проверке его состояния и принадлежности конкретному заказу.
Итоговое исправление получилось небольшим по объёму, но изменило принцип работы интеграции. Раньше сервер спрашивал: "Что сообщил клиент?". Теперь он проверяет: "Что на самом деле знает платёжная система?". Именно такой подход не позволяет превратить обычный HTTP-запрос в подтверждение несуществующей оплаты.