Перейти к содержимому

Уязвимость вебхука Юkassa позволяла подделать оплату брони одним запросом

Вебхук Ю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-запрос в подтверждение несуществующей оплаты.

Прокрутить вверх