Вебхук ЮKassa принимал payment.succeeded на веру. Подделать оплату брони можно было одним curl
Разбираю находку из технического аудита сервиса аренды авто: вебхук /payments/webhook/yukassa помечал бронь оплаченной по одному полю из тела запроса, которое присылает клиент, а не банк. Файл аудита датирован 5 июля 2026, фикс смержен 8 июля — привожу код до и после, тесты и то, что мы сознательно не стали чинить в этом же PR.
Как это выглядело до
Обработчик вебхука был устроен предельно просто:
async def handle_yukassa_webhook(self, payload: dict, db: AsyncSession) -> None:
"""Обрабатывает входящий webhook от ЮKassa."""
event_type = payload.get("event")
payment_data = payload.get("object", {})
gateway_payment_id = payment_data.get("id")
if not gateway_payment_id:
return
result = await db.execute(
select(Payment).where(Payment.gateway_payment_id == gateway_payment_id)
)
payment = result.scalar_one_or_none()
if not payment:
return
if event_type == "payment.succeeded":
payment.status = PaymentStatus.succeeded
# обновление брони, отправка уведомления...
Единственная проверка — что такой gateway_payment_id вообще существует в нашей базе. Дальше код верит полю event из тела запроса. Значит запрос вида
POST /payments/webhook/yukassa
{"event": "payment.succeeded", "object": {"id": "<gateway_payment_id уже созданной pending-брони>"}}
без единого заголовка авторизации переводил бронь в статус «оплачено». gateway_payment_id не секрет — он приходит клиенту сразу после создания платежа, в ответе на POST /bookings/{id}/pay. То есть у обычного пользователя, который просто открыл бронь и не заплатил, на руках было всё, что нужно для подделки.
Почему не помогает «просто добавить подпись»
Первая мысль — проверять подпись вебхука, как у Stripe или у большинства эквайеров. Полез разбираться, что у ЮKassa с этим есть. Официальная документация ЮKassa действительно описывает заголовок Webhook-Signature с HMAC-SHA256 по webhook_key из личного кабинета — но как дополнительный, не обязательный механизм. Базовая рекомендация ЮKassa — два других способа: проверка по IP-адресу отправителя и сверка статуса через обратный запрос к API. Наша интеграция на момент аудита webhook_key не заводила вообще, то есть подписи в заголовках попросту не было — только тело и IP.
От IP-фильтра отказались: инфраструктура за реверс-прокси, IP клиента до бэкенда доходит через цепочку заголовков, и сверять его с диапазоном ЮKassa означало бы держать этот список в актуальном состоянии и доверять X-Forwarded-For — то есть менять модель доверия на другую модель доверия. Обратный запрос надёжнее: спросить у самой ЮKassa, что происходит с этим платежом, вместо того чтобы гадать по заголовкам.
Что сделали вместо
async def _fetch_gateway_payment(self, gateway_payment_id: str) -> dict:
"""Запрашивает актуальное состояние платежа у ЮKassa.
Бросает исключение при недоступности API — вебхук ответит 5xx,
и ЮKassa повторит уведомление позже (fail-closed).
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.yookassa.ru/v3/payments/{gateway_payment_id}",
auth=(settings.YUKASSA_SHOP_ID, settings.YUKASSA_SECRET_KEY),
timeout=30,
)
resp.raise_for_status()
return resp.json()
Дальше в handle_yukassa_webhook статус и сумма берутся только из ответа этого запроса, тело входящего вебхука используется исключительно как триггер «сходи проверь»:
gateway_statusчитается из ответа API, не изpayload["event"];сумма и валюта сверяются с записью
Paymentв нашей базе — при расхождении статус не меняется, инцидент уходит в лог, аgateway_responseсохраняется целиком для разбора;если запрос к API ЮKassa падает (таймаут, 5xx на их стороне) — вебхук отвечает 5xx, ЮKassa переотправит уведомление позже. Это fail-closed: лучше временная задержка подтверждения, чем тихое принятие на веру;
финальные статусы (
succeeded,failed) повторным вебхуком не переписываются — без этого повторная доставка того же события просто лишний раз дергала бы API ЮKassa.
Дев-режим без ключей ЮKassa (YUKASSA_SHOP_ID/YUKASSA_SECRET_KEY пустые) оставили как было — доверяет телу запроса, потому что реального шлюза там нет и подделывать нечего.
Диф вышел небольшим: payment_service.py — +62/-5 строк, новый файл тестов — 205 строк.
Тесты
Файл tests/test_payment_webhook.py, 6 новых сценариев, эквайер замокан:
Сценарий | Что проверяет |
|---|---|
| подделанный |
| ЮKassa подтверждает |
| сумма и статус совпадают → бронь переходит в |
| отмена подтверждена шлюзом → |
| вебхук по уже |
| неизвестный |
Локально пакет тестов прошёл зелёным, ruff без замечаний. Отдельно стоит сказать, что именно поэтому баг до аудита не поймали: юнит-тесты проверяли логику обработчика саму на себя — с точки зрения теста «вебхук с payment.succeeded переводит бронь в paid» было ожидаемым поведением, а не дырой. Отсутствие проверки источника события не тестируется тестами, которые сами же и формируют доверенный вход. Нашли не автотестом, а при ручном чтении кода в рамках аудита.
Что не стали чинить в этом же PR
Три вещи сознательно оставлены на потом, обе — в описании PR:
IP-allowlist так и не добавлен — второй официально рекомендованный ЮKassa способ. Сейчас единственная линия защиты — обратная проверка через API, и это, строго говоря, делает вебхук почти декоративным: он всего лишь сигнал «сходи спроси у ЮKassa», а не источник истины. Работает, но лишний слой не помешал бы.
webhook_keyи HMAC-подпись не подключены. Раз уж заголовокWebhook-Signatureсуществует — его игнорирование означает один лишний сетевой запрос к ЮKassa на каждое уведомление, который можно было бы не делать, если бы подпись проверялась локально.Рядом, в соседнем PR (идемпотентность
initiate_payment, аудит HIGH №11), нашли ещё один смэлл:get_dbкоммитит транзакцию в конце запроса, а сервис вдобавок коммитит явно сам — двойнойcommit(). Решили не трогать: повторный коммит уже пустой транзакции безвреден, а разносить ответственность за транзакции — отдельная задача, и в PR с критической уязвимостью лишний рефакторинг только повышает шанс что-то сломать в неподходящий момент.
Изначально хотелось закрыть всё сразу — signature, IP-фильтр и двойной коммит заодно с основной дырой. Оставили только реверс-проверку, потому что диф с четырьмя параллельными изменениями в одном платёжном файле труднее ревьюить и опаснее откатывать, если что-то не так. Минимальный диф на критичном участке дороже одной строки в чейнджлоге.
Проверить, эксплуатировали ли дыру до фикса, задним числом нельзя: подделанный запрос от обычного payment.succeeded в логах ничем не отличается — оба просто POST на тот же путь. Единственная граница, которая появилась после фикса, — теперь у каждого такого события есть подтверждающий ответ ЮKassa, который можно поднять и сверить.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.