Разбираю находку из технического аудита сервиса аренды авто: вебхук /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, который можно поднять и сверить.
ссылка на оригинал статьи https://habr.com/ru/articles/1082170/