gendesign/tradein-mvp/backend/app/services/payments/notification.py
bot-backend 00d1f78668
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m10s
fix(tradein/payments): строгий разбор нотификации и отказ вместо догадок на враждебном входе
2026-08-06 15:48:57 +03:00

116 lines
7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Строгий типизированный разбор нотификации Т-Банк — ПОСЛЕ проверки подписи.
Вызывать `parse_notification()` только когда `token.verify_notification_token(...)`
уже вернул `True`. Разбор здесь НЕ проверяет подпись повторно — он только
превращает уже доверенный (по подписи) `dict` в типизированный объект, чтобы
сырой `dict` не утекал дальше в бизнес-логику (статус-машину платежа, запись
в БД).
ПОЧЕМУ строгий разбор — самостоятельный слой защиты, а не формальность:
алгоритм подписи Т-Банка конкатенирует значения полей БЕЗ разделителя между
ними (см. `token.py`, docstring модуля, шаг 4). Из-за этого символы могут
"перекладываться" между лексикографически соседними ключами так, что итоговая
строка для SHA-256 не меняется, хотя значения полей — меняются. Проверено
живым расчётом на официальном эталонном векторе: `Amount=1111, CardId="000000"`
даёт тот же Token, что и `Amount=11, CardId="11000000"` (доп. `1` "перетекла"
из `Amount` в начало `CardId`, потому что `Amount` < `CardId` лексикографически
и обе стоят подряд в конкатенации). Значит подпись сама по себе НЕ гарантирует,
что банк прислал именно ту сумму, которую записал у себя платёжный сервис —
это СВОЙСТВО алгоритма банка, менять его нельзя (мы не управляем форматом
Token, который реально пришлёт банк на проде).
КОНТРАКТ ДЛЯ PR-D (публичная ручка нотификации) — единственная реальная защита
от описанного выше перекладывания: `amount_kopecks` из `parse_notification()`
ОБЯЗАН быть сверен с уже сохранённым `payments.amount_kopecks` в БД (запись,
созданная на `init_payment()`, найденная по `order_id`/`payment_id` из этой же
нотификации) ДО того, как нотификация будет принята как валидное событие.
Если сумма из нотификации не совпадает с суммой в БД — это либо подделанная
нотификация (перекладывание символов дало другой `OrderId`/`Amount`-ключ и
подпись всё равно сошлась), либо рассинхронизация, но НЕ штатный кейс — то и
другое должно быть отказом, а не «примерно похоже, примем».
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
class NotificationParseError(ValueError):
"""Поле нотификации не соответствует ожидаемому типу — отказ, не догадка."""
@dataclass(frozen=True, slots=True)
class TBankNotification:
"""Типизированное тело нотификации Т-Банка ПОСЛЕ успешной проверки подписи.
`amount_kopecks` здесь — то, что ПРИСЛАЛ банк в текущем HTTP-запросе, а
НЕ подтверждённый источник истины сам по себе. См. docstring модуля —
сверка с `payments.amount_kopecks` в БД обязательна на вызывающей стороне.
"""
success: bool
status: str
order_id: str
payment_id: str
terminal_key: str
amount_kopecks: int
def parse_notification(payload: dict[str, Any]) -> TBankNotification:
"""Строгий разбор `payload` в `TBankNotification`.
Вызывать ТОЛЬКО после `token.verify_notification_token(payload, password)
is True` — эта функция подпись не проверяет.
Правила (без исключений, без «примерно разберём»):
- `Success` — только настоящий `bool` (не строка `"true"`, не `1`);
- `Amount` — только `int`; `bool` — подкласс `int` в Python
(`isinstance(True, int) is True`), поэтому проверяется и отсекается
ДО проверки на `int`, иначе `Success`-подобное поле молча прошло бы
как сумма;
- `Status`, `OrderId`, `PaymentId`, `TerminalKey` — только непустой `str`.
Любое несоответствие — `NotificationParseError` с указанием поля,
ожидаемого типа и того, что реально пришло.
"""
if not isinstance(payload, dict):
raise NotificationParseError(f"payload должен быть dict, получено {type(payload).__name__}")
return TBankNotification(
success=_require_strict_bool(payload, "Success"),
status=_require_nonempty_str(payload, "Status"),
order_id=_require_nonempty_str(payload, "OrderId"),
payment_id=_require_nonempty_str(payload, "PaymentId"),
terminal_key=_require_nonempty_str(payload, "TerminalKey"),
amount_kopecks=_require_strict_int(payload, "Amount"),
)
def _require_strict_bool(payload: dict[str, Any], key: str) -> bool:
value = payload.get(key)
if not isinstance(value, bool):
raise NotificationParseError(
f"{key} должен быть bool, получено {type(value).__name__}={value!r}"
)
return value
def _require_strict_int(payload: dict[str, Any], key: str) -> int:
value = payload.get(key)
# bool — подкласс int в Python: проверяем и отсекаем ДО isinstance(value, int),
# иначе True/False молча прошли бы как Amount=1/Amount=0.
if isinstance(value, bool) or not isinstance(value, int):
raise NotificationParseError(
f"{key} должен быть int (не bool/str/float), получено {type(value).__name__}={value!r}"
)
return value
def _require_nonempty_str(payload: dict[str, Any], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value:
raise NotificationParseError(
f"{key} должен быть непустой str, получено {type(value).__name__}={value!r}"
)
return value