Вебхук Ю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 новых сценариев, эквайер замокан:

Сценарий

Что проверяет

test_forged_webhook_ignored_when_gateway_says_pending

подделанный payment.succeeded, но у ЮKassa платёж ещё pending → статус в базе не меняется

test_webhook_amount_mismatch_ignored

ЮKassa подтверждает succeeded, но сумма не совпадает (1 ₽ вместо 2300 ₽) → статус не меняется

test_webhook_confirmed_success_marks_paid

сумма и статус совпадают → бронь переходит в paid

test_webhook_canceled_marks_failed

отмена подтверждена шлюзом → failed

test_webhook_final_status_not_rewritten

вебхук по уже succeeded платежу — _fetch_gateway_payment не должен вызываться вовсе (замокан на AssertionError при вызове)

test_webhook_unknown_payment_ignored

неизвестный gateway_payment_id — тоже не должен трогать API

Локально пакет тестов прошёл зелёным, ruff без замечаний. Отдельно стоит сказать, что именно поэтому баг до аудита не поймали: юнит-тесты проверяли логику обработчика саму на себя — с точки зрения теста «вебхук с payment.succeeded переводит бронь в paid» было ожидаемым поведением, а не дырой. Отсутствие проверки источника события не тестируется тестами, которые сами же и формируют доверенный вход. Нашли не автотестом, а при ручном чтении кода в рамках аудита.

Что не стали чинить в этом же PR

Три вещи сознательно оставлены на потом, обе — в описании PR:

  1. IP-allowlist так и не добавлен — второй официально рекомендованный ЮKassa способ. Сейчас единственная линия защиты — обратная проверка через API, и это, строго говоря, делает вебхук почти декоративным: он всего лишь сигнал «сходи спроси у ЮKassa», а не источник истины. Работает, но лишний слой не помешал бы.

  2. webhook_key и HMAC-подпись не подключены. Раз уж заголовок Webhook-Signature существует — его игнорирование означает один лишний сетевой запрос к ЮKassa на каждое уведомление, который можно было бы не делать, если бы подпись проверялась локально.

  3. Рядом, в соседнем PR (идемпотентность initiate_payment, аудит HIGH №11), нашли ещё один смэлл: get_db коммитит транзакцию в конце запроса, а сервис вдобавок коммитит явно сам — двойной commit(). Решили не трогать: повторный коммит уже пустой транзакции безвреден, а разносить ответственность за транзакции — отдельная задача, и в PR с критической уязвимостью лишний рефакторинг только повышает шанс что-то сломать в неподходящий момент.

Изначально хотелось закрыть всё сразу — signature, IP-фильтр и двойной коммит заодно с основной дырой. Оставили только реверс-проверку, потому что диф с четырьмя параллельными изменениями в одном платёжном файле труднее ревьюить и опаснее откатывать, если что-то не так. Минимальный диф на критичном участке дороже одной строки в чейнджлоге.

Проверить, эксплуатировали ли дыру до фикса, задним числом нельзя: подделанный запрос от обычного payment.succeeded в логах ничем не отличается — оба просто POST на тот же путь. Единственная граница, которая появилась после фикса, — теперь у каждого такого события есть подтверждающий ответ ЮKassa, который можно поднять и сверить.

ссылка на оригинал статьи https://habr.com/ru/articles/1082170/