From 5527bf0247ab779e0ca8abe749929edad846655b Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 4 Jun 2026 14:18:35 +0300 Subject: [PATCH] fix: sanitize PayKilla invoice text --- backend/bot/payment_providers/paykilla.py | 86 +++++++++++++++++++++-- docs/features/payments.md | 2 + tests/test_security.py | 25 +++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/backend/bot/payment_providers/paykilla.py b/backend/bot/payment_providers/paykilla.py index 297ba4c..3cc72ae 100644 --- a/backend/bot/payment_providers/paykilla.py +++ b/backend/bot/payment_providers/paykilla.py @@ -81,6 +81,76 @@ _FAILED_EVENTS = { "PAYMENT_CANCELLED", "COMPLIANCE_FAILED", } +_CYRILLIC_TO_LATIN = str.maketrans( + { + "А": "A", + "Б": "B", + "В": "V", + "Г": "G", + "Д": "D", + "Е": "E", + "Ё": "E", + "Ж": "Zh", + "З": "Z", + "И": "I", + "Й": "Y", + "К": "K", + "Л": "L", + "М": "M", + "Н": "N", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Т": "T", + "У": "U", + "Ф": "F", + "Х": "H", + "Ц": "Ts", + "Ч": "Ch", + "Ш": "Sh", + "Щ": "Sch", + "Ъ": "", + "Ы": "Y", + "Ь": "", + "Э": "E", + "Ю": "Yu", + "Я": "Ya", + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "е": "e", + "ё": "e", + "ж": "zh", + "з": "z", + "и": "i", + "й": "y", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "у": "u", + "ф": "f", + "х": "h", + "ц": "ts", + "ч": "ch", + "ш": "sh", + "щ": "sch", + "ъ": "", + "ы": "y", + "ь": "", + "э": "e", + "ю": "yu", + "я": "ya", + } +) class PaykillaConfig(ProviderEnvConfig): @@ -203,14 +273,22 @@ class PaykillaPresentation(ProviderEnvConfig): TELEGRAM_EMOJI: Optional[str] = None -def _clean_paykilla_text(value: Any, *, fallback: str, max_length: int = 255) -> str: +def _normalize_paykilla_text(value: Any) -> str: text = str(value or "") + text = text.translate(_CYRILLIC_TO_LATIN) text = re.sub(r"[-\u2010-\u2015]", " ", text) - text = re.sub(r"[^\w\s.,]", "", text, flags=re.UNICODE) + text = text.encode("ascii", "ignore").decode("ascii") + text = re.sub(r"[^A-Za-z0-9_\s.,]", "", text) + return re.sub(r"\s+", " ", text).strip() + + +def _clean_paykilla_text(value: Any, *, fallback: str, max_length: int = 255) -> str: + text = _normalize_paykilla_text(value) + fallback_text = _normalize_paykilla_text(fallback) or "Payment" text = re.sub(r"\s+", " ", text).strip() if not text: - text = fallback - return text[:max_length].strip() or fallback[:max_length] + text = fallback_text + return text[:max_length].strip() or fallback_text[:max_length].strip() or "Payment" def _payment_currencies(config: PaykillaConfig) -> List[str]: diff --git a/docs/features/payments.md b/docs/features/payments.md index 9cd4c77..fba963e 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -158,6 +158,8 @@ Heleket используется для крипто-инвойсов с отд PayKilla используется для крипто-инвойсов V2 через hosted checkout `https://gopay.paykilla.com/{invoice_id}`. API-запросы подписываются HMAC-SHA256, webhook проверяется по заголовку `X-API-SIGN` и raw body. +PayKilla строго валидирует текстовые поля invoice. Minishop перед отправкой автоматически заменяет тире пробелами, транслитерирует кириллицу и оставляет только ASCII-буквы, цифры, пробелы, `_`, `.`, `,` в `purpose` и `description`. + Какие полномочия нужны API key: 1. В PayKilla Dashboard откройте **Settings -> API keys**. diff --git a/tests/test_security.py b/tests/test_security.py index 7ffa40b..0b9235e 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -23,6 +23,7 @@ from bot.payment_providers.heleket import HeleketConfig, HeleketService, _comput from bot.payment_providers.paykilla import ( PaykillaConfig, PaykillaService, + _clean_paykilla_text, _sign_query, _webhook_signature, ) @@ -219,6 +220,7 @@ class PaykillaServiceTests(unittest.TestCase): WEBHOOK_BASE_URL="https://shop.example", trusted_proxies=["127.0.0.1"], ) + service._default_return_url = "test_bot" return service def test_sign_query_uses_timestamp_and_recv_window_only(self): @@ -232,6 +234,29 @@ class PaykillaServiceTests(unittest.TestCase): self.assertEqual(query, "timestamp=1738800000000&recvWindow=5000") self.assertEqual(signature, expected) + def test_clean_text_transliterates_russian_description_for_invoice_fields(self): + text = _clean_paykilla_text( + "Оплата подписки на 1 мес. - тариф «Базовый» ✅", + fallback="Payment 556", + ) + + self.assertEqual(text, "Oplata podpiski na 1 mes. tarif Bazovyy") + self.assertRegex(text, r"^[A-Za-z0-9_\s.,]+$") + + def test_invoice_body_uses_ascii_safe_purpose_and_description(self): + service = self._make_service() + + body = service._invoice_body( + payment_db_id=556, + amount=100, + currency="RUB", + description="Оплата подписки на 1 мес. - тариф «Базовый» ✅", + ) + + self.assertEqual(body["purpose"], "Oplata podpiski na 1 mes. tarif Bazovyy") + self.assertEqual(body["description"], body["purpose"]) + self.assertRegex(body["purpose"], r"^[A-Za-z0-9_\s.,]+$") + def test_verify_webhook_signature_accepts_raw_body_signature(self): service = self._make_service() raw_body = (