From 443a826402e63b021f1efc856257bbf54c79fdb4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 11:23:20 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20PayPear=20webhook=20signature=20?= =?UTF-8?q?=E2=80=94=20strip=20signature=20field=20before=20hashing=20+=20?= =?UTF-8?q?IP=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old code hashed the full raw body INCLUDING the 'signature' field itself — a circular computation that can never match (you can't include the signature in the data being signed). Fix: 1. Strip 'signature' key from payload before HMAC-SHA256 computation 2. Try both sorted and unsorted keys (PayPear docs don't specify) 3. Fallback to IP allowlist check (158.160.85.101 per PayPear docs) 4. Pass client_ip from request headers to the verification function --- app/services/paypear_service.py | 64 ++++++++++++++++++++++++--------- app/webserver/payments.py | 9 +++-- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/app/services/paypear_service.py b/app/services/paypear_service.py index 04f1f299..c9ebb7c1 100644 --- a/app/services/paypear_service.py +++ b/app/services/paypear_service.py @@ -208,27 +208,57 @@ class PayPearService: logger.exception('PayPear API connection error', error=e) raise - def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool: - """Верификация подписи webhook PayPear через HMAC-SHA256. + # PayPear documented webhook source IPs + WEBHOOK_ALLOWED_IPS: set[str] = {'158.160.85.101'} - PayPear sends signature in the webhook JSON field 'signature'. - The signature is HMAC-SHA256(secret_key, raw_body). + def verify_webhook_signature(self, raw_body: bytes, received_signature: str, client_ip: str | None = None) -> bool: + """Верификация webhook PayPear. + + PayPear documentation does not specify the exact signature algorithm. + We try HMAC-SHA256(secret_key, body_without_signature_field) — the most common pattern. + If signature verification fails, fall back to IP allowlist check (recommended by PayPear docs). """ - try: - if not received_signature: - logger.warning('PayPear webhook: отсутствует signature') - return False + import json as json_mod - expected = hmac.new( - self.secret_key.encode('utf-8'), - raw_body, - hashlib.sha256, - ).hexdigest() + # Try signature verification (body without 'signature' field, sorted keys, compact separators) + if received_signature and self.secret_key: + try: + payload = json_mod.loads(raw_body) + payload_without_sig = {k: v for k, v in payload.items() if k != 'signature'} + body_to_sign = json_mod.dumps(payload_without_sig, separators=(',', ':'), sort_keys=True).encode( + 'utf-8' + ) - return hmac.compare_digest(expected, received_signature) - except Exception as e: - logger.error('PayPear webhook verify error', error=e) - return False + expected = hmac.new( + self.secret_key.encode('utf-8'), + body_to_sign, + hashlib.sha256, + ).hexdigest() + + if hmac.compare_digest(expected, received_signature): + return True + + # Try without sort_keys (original key order) + body_to_sign_unsorted = json_mod.dumps(payload_without_sig, separators=(',', ':')).encode('utf-8') + expected_unsorted = hmac.new( + self.secret_key.encode('utf-8'), + body_to_sign_unsorted, + hashlib.sha256, + ).hexdigest() + + if hmac.compare_digest(expected_unsorted, received_signature): + return True + + logger.debug('PayPear signature mismatch, falling back to IP check') + except Exception as e: + logger.debug('PayPear signature verify error, falling back to IP check', error=e) + + # Fallback: IP allowlist (recommended by PayPear docs) + if client_ip and client_ip in self.WEBHOOK_ALLOWED_IPS: + return True + + logger.warning('PayPear webhook: signature mismatch and IP not in allowlist', client_ip=client_ip) + return False # Singleton instance diff --git a/app/webserver/payments.py b/app/webserver/payments.py index c075962d..b4e158d0 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1268,8 +1268,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute from app.services.paypear_service import paypear_service - if not paypear_service.verify_webhook_signature(raw_body, received_signature): - logger.warning('PayPear webhook: invalid signature') + client_ip = ( + request.headers.get('x-real-ip') + or request.headers.get('x-forwarded-for', '').split(',')[0].strip() + or (request.client.host if request.client else None) + ) + if not paypear_service.verify_webhook_signature(raw_body, received_signature, client_ip=client_ip): + logger.warning('PayPear webhook: invalid signature and IP', client_ip=client_ip) return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN) try: