fix: fix Platega and CryptoBot webhook verification
Platega: handle verification ping POST without auth headers (empty body → 200 OK) CryptoBot: always use API token for signature verification per docs, not WEBHOOK_SECRET CryptoBot: reject requests without signature in both FastAPI and aiohttp handlers Remove dead self.webhook_secret from CryptoBotService Update tests to match new behavior
This commit is contained in:
Vendored
+20
-13
@@ -16,7 +16,6 @@ class CryptoBotService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.api_token = settings.CRYPTOBOT_API_TOKEN
|
self.api_token = settings.CRYPTOBOT_API_TOKEN
|
||||||
self.base_url = settings.get_cryptobot_base_url()
|
self.base_url = settings.get_cryptobot_base_url()
|
||||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
|
||||||
|
|
||||||
async def _make_request(
|
async def _make_request(
|
||||||
self,
|
self,
|
||||||
@@ -123,37 +122,45 @@ class CryptoBotService:
|
|||||||
return await self._make_request('GET', 'getExchangeRates')
|
return await self._make_request('GET', 'getExchangeRates')
|
||||||
|
|
||||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||||
# По документации CryptoBot, ключ — SHA256 от API токена
|
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
|
||||||
token = self.webhook_secret or self.api_token
|
token = self.api_token
|
||||||
if not token:
|
if not token:
|
||||||
logger.warning('CryptoBot webhook secret и API token не настроены')
|
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
secret_hash = hashlib.sha256(token.encode()).digest()
|
secret_hash = hashlib.sha256(token.encode()).digest()
|
||||||
|
|
||||||
# 1. Raw body — CryptoBot скорее всего шлёт compact JSON
|
# 1. Raw body — CryptoBot шлёт compact JSON
|
||||||
expected = hmac.new(secret_hash, body.encode('utf-8'), hashlib.sha256).hexdigest()
|
expected = hmac.new(secret_hash, body.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||||
if hmac.compare_digest(signature, expected):
|
if hmac.compare_digest(signature, expected):
|
||||||
logger.info('CryptoBot webhook подпись валидна')
|
logger.info('CryptoBot webhook подпись валидна (raw body)')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 2. Fallback: JSON.stringify(body) как в доке — re-serialize compact
|
# 2. Fallback: re-serialize compact JSON
|
||||||
parsed = json.loads(body)
|
parsed = json.loads(body)
|
||||||
check_string = json.dumps(parsed, separators=(',', ':'), ensure_ascii=False)
|
check_string = json.dumps(parsed, separators=(',', ':'), ensure_ascii=False)
|
||||||
expected = hmac.new(secret_hash, check_string.encode('utf-8'), hashlib.sha256).hexdigest()
|
expected_reserialized = hmac.new(secret_hash, check_string.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||||
if hmac.compare_digest(signature, expected):
|
if hmac.compare_digest(signature, expected_reserialized):
|
||||||
logger.info('CryptoBot webhook подпись валидна (re-serialized)')
|
logger.info('CryptoBot webhook подпись валидна (re-serialized)')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 3. Fallback: ensure_ascii=True (если CryptoBot эскейпит unicode)
|
# 3. Fallback: ensure_ascii=True
|
||||||
check_string_ascii = json.dumps(parsed, separators=(',', ':'), ensure_ascii=True)
|
check_string_ascii = json.dumps(parsed, separators=(',', ':'), ensure_ascii=True)
|
||||||
expected = hmac.new(secret_hash, check_string_ascii.encode('utf-8'), hashlib.sha256).hexdigest()
|
expected_ascii = hmac.new(secret_hash, check_string_ascii.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||||
if hmac.compare_digest(signature, expected):
|
if hmac.compare_digest(signature, expected_ascii):
|
||||||
logger.info('CryptoBot webhook подпись валидна (ascii-escaped)')
|
logger.info('CryptoBot webhook подпись валидна (ascii-escaped)')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.error('Неверная подпись CryptoBot webhook')
|
logger.error(
|
||||||
|
'Неверная подпись CryptoBot webhook',
|
||||||
|
received_signature=signature,
|
||||||
|
expected_raw=expected,
|
||||||
|
expected_reserialized=expected_reserialized,
|
||||||
|
body_length=len(body),
|
||||||
|
token_length=len(token),
|
||||||
|
token_prefix=token[:4] + '...',
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Vendored
+4
-1
@@ -377,7 +377,10 @@ class WebhookServer:
|
|||||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||||
logger.info('CryptoBot Signature', signature=signature)
|
logger.info('CryptoBot Signature', signature=signature)
|
||||||
|
|
||||||
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
|
if settings.CRYPTOBOT_API_TOKEN:
|
||||||
|
if not signature:
|
||||||
|
logger.error('CryptoBot webhook без подписи')
|
||||||
|
return web.json_response({'status': 'error', 'reason': 'missing_signature'}, status=401)
|
||||||
from app.external.cryptobot import CryptoBotService
|
from app.external.cryptobot import CryptoBotService
|
||||||
|
|
||||||
cryptobot_service = CryptoBotService()
|
cryptobot_service = CryptoBotService()
|
||||||
|
|||||||
@@ -571,7 +571,7 @@ class BotConfigurationService:
|
|||||||
'format': 'Булево значение.',
|
'format': 'Булево значение.',
|
||||||
'example': 'Включите после указания токена API и секрета вебхука.',
|
'example': 'Включите после указания токена API и секрета вебхука.',
|
||||||
'warning': 'Пустой токен или неверный вебхук приведут к отказам платежей.',
|
'warning': 'Пустой токен или неверный вебхук приведут к отказам платежей.',
|
||||||
'dependencies': 'CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET',
|
'dependencies': 'CRYPTOBOT_API_TOKEN',
|
||||||
},
|
},
|
||||||
'PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED': {
|
'PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED': {
|
||||||
'description': (
|
'description': (
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
|||||||
)
|
)
|
||||||
|
|
||||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||||
secret = settings.CRYPTOBOT_WEBHOOK_SECRET or settings.CRYPTOBOT_API_TOKEN
|
secret = settings.CRYPTOBOT_API_TOKEN
|
||||||
if secret:
|
if secret:
|
||||||
if not signature:
|
if not signature:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -682,6 +682,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
|||||||
async def platega_webhook(request: Request) -> JSONResponse:
|
async def platega_webhook(request: Request) -> JSONResponse:
|
||||||
merchant_id = request.headers.get('X-MerchantId', '')
|
merchant_id = request.headers.get('X-MerchantId', '')
|
||||||
secret = request.headers.get('X-Secret', '')
|
secret = request.headers.get('X-Secret', '')
|
||||||
|
raw_body = await request.body()
|
||||||
|
if not merchant_id and not secret and not raw_body.strip():
|
||||||
|
logger.info('Platega webhook verification ping (no auth headers, empty body)')
|
||||||
|
return JSONResponse({'status': 'ok'})
|
||||||
if merchant_id != (settings.PLATEGA_MERCHANT_ID or '') or secret != (settings.PLATEGA_SECRET or ''):
|
if merchant_id != (settings.PLATEGA_MERCHANT_ID or '') or secret != (settings.PLATEGA_SECRET or ''):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{'status': 'error', 'reason': 'unauthorized'},
|
{'status': 'error', 'reason': 'unauthorized'},
|
||||||
|
|||||||
+3
-4
@@ -27,7 +27,6 @@ def anyio_backend() -> str:
|
|||||||
def _enable_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _enable_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False)
|
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False)
|
||||||
monkeypatch.setattr(type(settings), 'get_cryptobot_base_url', lambda self: 'https://cryptobot.test', raising=False)
|
monkeypatch.setattr(type(settings), 'get_cryptobot_base_url', lambda self: 'https://cryptobot.test', raising=False)
|
||||||
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'secret', raising=False)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio('asyncio')
|
@pytest.mark.anyio('asyncio')
|
||||||
@@ -69,7 +68,7 @@ async def test_make_request_returns_none_without_token(monkeypatch: pytest.Monke
|
|||||||
|
|
||||||
|
|
||||||
def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'supersecret', raising=False)
|
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'supersecret', raising=False)
|
||||||
service = CryptoBotService()
|
service = CryptoBotService()
|
||||||
|
|
||||||
body = '{"invoice_id":1}'
|
body = '{"invoice_id":1}'
|
||||||
@@ -80,7 +79,7 @@ def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert service.verify_webhook_signature(body, 'invalid') is False
|
assert service.verify_webhook_signature(body, 'invalid') is False
|
||||||
|
|
||||||
|
|
||||||
def test_verify_webhook_signature_without_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_verify_webhook_signature_without_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', '', raising=False)
|
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', '', raising=False)
|
||||||
service = CryptoBotService()
|
service = CryptoBotService()
|
||||||
assert service.verify_webhook_signature('{}', 'anything') is True
|
assert service.verify_webhook_signature('{}', 'anything') is True
|
||||||
|
|||||||
Reference in New Issue
Block a user