From 4a301d6f264331f722f60244bcc335c976124997 Mon Sep 17 00:00:00 2001 From: Gabriel Moraes Date: Thu, 2 Jul 2026 12:01:10 -0300 Subject: [PATCH] =?UTF-8?q?feat(webhook):=20verifica=C3=A7=C3=A3o=20de=20f?= =?UTF-8?q?rescor=20opcional=20(anti-replay)=20em=20verify=5Fwebhook=5Fsig?= =?UTF-8?q?nature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novo parâmetro opcional `tolerance_seconds`: após validar o HMAC, rejeita entregas cujo `sentAt` (no corpo assinado) esteja fora da janela. Aditivo e retrocompatível (sem o param, comportamento idêntico). Pareado com o backend, que agora assina `id` + `sentAt`; deduplique por `id` para idempotência. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/smsgo/client.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/smsgo/client.py b/src/smsgo/client.py index 63f08f9..b83fe57 100644 --- a/src/smsgo/client.py +++ b/src/smsgo/client.py @@ -14,6 +14,7 @@ import hmac import json import time +from datetime import datetime, timezone import urllib.error import urllib.parse import urllib.request @@ -680,6 +681,7 @@ def verify_webhook_signature( raw_body: Union[str, bytes, None], signature_header: Optional[str], secret: str, + tolerance_seconds: Optional[int] = None, ) -> bool: """Valida a assinatura ``X-SMSGo-Signature`` de um webhook de saída. @@ -690,10 +692,14 @@ def verify_webhook_signature( raw_body: corpo bruto exatamente como recebido (``str`` ou ``bytes``). signature_header: valor do cabeçalho ``X-SMSGo-Signature``. secret: segredo do webhook (``get_webhook().secret``). + tolerance_seconds: se informado, além da assinatura exige que o + ``sentAt`` do corpo esteja dentro desta janela (anti-replay). Sem + ele, o comportamento é idêntico ao anterior (só assinatura). + Deduplicar por ``id`` do corpo continua a cargo do receptor. Returns: - ``True`` se a assinatura confere; ``False`` caso contrário (inclui - ``None``/vazio). Nunca levanta exceção. + ``True`` se a assinatura confere (e o frescor, quando exigido); + ``False`` caso contrário (inclui ``None``/vazio). Nunca levanta exceção. """ if not signature_header or not secret or raw_body is None: return False @@ -704,7 +710,23 @@ def verify_webhook_signature( # exceção (compare_digest com dois str exige ASCII) — apenas retornar False. expected = ("sha256=" + digest).encode("ascii") provided = signature_header.encode("utf-8", "replace") - return hmac.compare_digest(expected, provided) + if not hmac.compare_digest(expected, provided): + return False + + if tolerance_seconds is not None: + try: + sent_at = json.loads(body_bytes.decode("utf-8")).get("sentAt") + if not sent_at: + return False + # ISO-8601 com "Z" (UTC): fromisoformat só aceita offset em +00:00. + sent = datetime.fromisoformat(str(sent_at).replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + if abs((now - sent).total_seconds()) > tolerance_seconds: + return False + except Exception: + return False + + return True # -------------------------------------------------------------------------- #