fix(tradein/payments): строгий разбор нотификации и отказ вместо догадок на враждебном входе #2737
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2737
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-payments-notification-hardening"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Усиление слоя платежей МЕРЫ (
app/services/payments/*) перед публичным эндпоинтом нотификации Т-Банка (PR-D). Без изменений роутеров/RBAC/config — только сам платёжный слой.Свойство алгоритма подписи Т-Банка (важно, зафиксировано намеренно)
Алгоритм
Tokenконкатенирует значения полей payload без разделителя между ними (см.token.py). Из-за этого символы могут "перекладываться" между лексикографически соседними ключами так, что итоговая подпись всё равно сходится — это свойство алгоритма банка, которое нельзя изменить (мы не выбираем формат Token, который реально пришлёт банк на проде).Проверено живым расчётом на официальном эталонном векторе документации:
Amount=1111, CardId="000000"даёт тот же Token, что иAmount=11, CardId="11000000"(лишняя1"перетекает" из концаAmountв началоCardId, потому чтоAmount < CardIdлексикографически и оба поля стоят рядом в конкатенации).Следствие: подпись сама по себе не гарантирует, что банк прислал именно ту сумму, которую он в реальности захолдировал/списал. Единственная реальная защита от этого — обработчик нотификации (PR-D) обязан сверять
amount_kopecksиз нотификации с уже сохранённымpayments.amount_kopecksв БД (запись, созданная наinit_payment(), найденная поorder_id/payment_id) до того, как нотификация принимается как валидное событие. Расхождение = отказ, а не «примерно похоже — примем». Это зафиксировано docstring'ами вnotification.py, чтобы следующий автор (PR-D) не пропустил.Что сделано
token.py—verify_notification_tokenбольше не кидает исключение на враждебном входе.payloadнеdict(например список) →False, а неAttributeError.Tokenс не-ASCII символами →False, а неTypeErrorизhmac.compare_digest.sign()на мусорном payload (например float-поле после п.3) →False, а не проброс исключения."OK"банк трактует как временный сбой и ретраит уведомление почасово в течение суток.Новый модуль
notification.py—parse_notification().Строгий типизированный разбор
dict→TBankNotification(frozen dataclass) после успешной проверки подписи. Сыройdictдальше в бизнес-логику не уходит.Success— только настоящийbool(не"true", не1).Amount— толькоint;bool(подклассintв Python) отсекается отдельно ДО общейint-проверки.Status/OrderId/PaymentId/TerminalKey— только непустойstr.NotificationParseErrorс указанием поля и того, что реально пришло.Убрана угадывающая сериализация float в
sign().Раньше
floatсериализовался черезformat(value, "f")+ rstrip нулей — недокументированный формат. Расхождение:0.1 + 0.2подписывалось бы как"0.3", аjson.dumps(0.1 + 0.2)реально даёт"0.30000000000000004"— Token не соответствовал бы факту, что уходит в JSON-теле. Теперьfloatв подписываемых полях → явныйTokenSigningError. Тесты, закреплявшие float-поведение как спецификацию, переписаны под новое поведение (падение вместо угадывания).Докстринги-контракты для PR-D:
parse_notification/TBankNotification— сверка суммы сpayments.amount_kopecksв БД обязательна (см. выше).confirm()— послеTBankApiErrorслепойcancel()запрещён: таймаут мог прийти на уже успевшемConfirm,cancel()вернёт уже захваченные банком деньги. Сначалаget_state().init_payment()— после сетевой ошибки разбираться черезcheck_order(), не повторятьinit_payment()вслепую (риск второго холда на тот жеOrderId).tbank_client.py— worst case одного вызова ~74с (4 попытки × 15с таймаут + backoff 2+4+8=14с), а бюджет ответа банку на нотификацию ~10с → исходящий HTTP внутри обработчика нотификации запрещён.Тесты на ретраи
Confirm/Cancel. Раньше retry-покрытие шло только черезinit_payment/get_state— денежные вызовы не были покрыты 5xx/network-error/4xx/gives-up сценариями.Границы (соблюдены)
Роутеров/эндпоинтов не создавал,
rbac.py/roles.yaml/Caddyfile/config.py/main.py/миграции не трогал. Ничего не деплоил. Сеть в тестах не используется — толькоhttpx.MockTransport.Test plan
uv run pytest tests/test_payments_token.py tests/test_payments_notification.py tests/test_payments_receipt.py tests/services/payments/— 108 passedruff check— all checks passedruff format --check— all files already formattedtest_payments_token.py(явно предписано задачей) — остальные (эталонные векторы, verify-тесты) не тронуты