The Jerusalem PostA recipe for disaster: Edward Miliband, Britain, and relations with Israel - opinionInquirerManila Water to complete key utility relocations for subway projectRTP DesportoI Liga. Moreirense - MarítimoESPNHarbaugh: Giants' victory vs. Cowboys in debut ranks as No. 1 win 'right now'Daily MaverickElephant culling debate is about governance, not activismESPN Deportes¿LeBron James ganará un título con su cuarto equipo en la NBA?וואלהדיווח איראני: איש דת סוני נורה למוות ע"י חמושים לא מזוהים בדרום מזרח המדינהThe Hollywood Reporter‘Adults’ Defies Sophomore Slump With Ratings GrowthVanguardKing’s College crisis: Alausa, unions begin fresh talksBillboardHayley Williams Debuts Easy-to-Recreate Tour Hair as The Hayley Williams Show Kicks OffVarietyGoogle’s Sean Downey on the YouTube TIFF Takeover, Oscars Ad Integration and Creators vs. InfluencersDeadline‘The Murder Of JonBenét Ramsey’ Gets December Release Date On Netflix & First-Look Photo Of Melissa McCarthy & Clive Owen As Girl’s Parents
The Daily Newsstand · Free, Always
Monday, September 14, 2026

Вебхук ЮKassa принимал payment.succeeded на веру. Подделать оплату брони можно было одним curl

Translate

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.