From 6b7dba9155f7f04e01927c3e3ff9b17140b367b3 Mon Sep 17 00:00:00 2001 From: Cristiano Betta Date: Thu, 1 Oct 2026 17:17:33 +0200 Subject: [PATCH 1/2] fix(webhooks): compare signatures in constant time The old comparison stopped at the first character that differed, so how long a rejection took showed how much of a guessed signature was right. Co-Authored-By: Claude Opus 5.5 --- src/gr4vy/webhooks.py | 3 ++- tests/test_webhooks.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/gr4vy/webhooks.py b/src/gr4vy/webhooks.py index e5534200..159d1c08 100644 --- a/src/gr4vy/webhooks.py +++ b/src/gr4vy/webhooks.py @@ -35,7 +35,8 @@ def verify_webhook( hashlib.sha256 ).hexdigest() - if expected_signature not in signatures: + # Compare in constant time, so the check doesn't leak how much of one matched. + if not any(hmac.compare_digest(expected_signature, signature) for signature in signatures): raise ValueError("No matching signature found") if timestamp_tolerance and timestamp < time.time() - timestamp_tolerance: diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index caf2d01f..2b50ee8c 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -65,3 +65,18 @@ def test_verify_webhook_missing_timestamp_header(): with pytest.raises(ValueError, match="Missing header values"): verify_webhook(payload, secret, signature_header, timestamp_header, timestamp_tolerance) + + +def test_verify_webhook_accepts_a_valid_signature_in_any_position(): + secret = "Ik4L-8FH0ihWczctcIPXZRR_8F0fPNgmhEfVBbZ3zNwqQVa1Or4tBz4Pgw2eNaVDod7H56Y268h_wohEUaWbUg" + signature_header = "other,78aca0c78005107a654a957b8566fa6e0e5e06aea92d7da72a6da9e5a690d013" + + verify_webhook("payload", secret, signature_header, "1744018920", 0) + + +def test_verify_webhook_rejects_a_nearly_matching_signature(): + secret = "Ik4L-8FH0ihWczctcIPXZRR_8F0fPNgmhEfVBbZ3zNwqQVa1Or4tBz4Pgw2eNaVDod7H56Y268h_wohEUaWbUg" + signature_header = "78aca0c78005107a654a957b8566fa6e0e5e06aea92d7da72a6da9e5a690d014" + + with pytest.raises(ValueError, match="No matching signature found"): + verify_webhook("payload", secret, signature_header, "1744018920", 0) From 72986e8746f9f9f259ee8af13bc8b1f6de557862 Mon Sep 17 00:00:00 2001 From: Cristiano Betta Date: Fri, 2 Oct 2026 16:52:48 +0100 Subject: [PATCH 2/2] fix(webhooks): reject non-ASCII signatures instead of raising TypeError hmac.compare_digest raises TypeError on non-ASCII strings, so a header containing one skipped the ValueError path and any valid signature after it. Compare bytes instead. Co-Authored-By: Claude Opus 5.5 --- src/gr4vy/webhooks.py | 4 +++- tests/test_webhooks.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/gr4vy/webhooks.py b/src/gr4vy/webhooks.py index 159d1c08..58eb66a4 100644 --- a/src/gr4vy/webhooks.py +++ b/src/gr4vy/webhooks.py @@ -36,7 +36,9 @@ def verify_webhook( ).hexdigest() # Compare in constant time, so the check doesn't leak how much of one matched. - if not any(hmac.compare_digest(expected_signature, signature) for signature in signatures): + # Bytes, because compare_digest raises TypeError on non-ASCII strings. + expected = expected_signature.encode() + if not any(hmac.compare_digest(expected, signature.encode()) for signature in signatures): raise ValueError("No matching signature found") if timestamp_tolerance and timestamp < time.time() - timestamp_tolerance: diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 2b50ee8c..3789b0b0 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -80,3 +80,17 @@ def test_verify_webhook_rejects_a_nearly_matching_signature(): with pytest.raises(ValueError, match="No matching signature found"): verify_webhook("payload", secret, signature_header, "1744018920", 0) + + +def test_verify_webhook_rejects_a_non_ascii_signature(): + secret = "Ik4L-8FH0ihWczctcIPXZRR_8F0fPNgmhEfVBbZ3zNwqQVa1Or4tBz4Pgw2eNaVDod7H56Y268h_wohEUaWbUg" + + with pytest.raises(ValueError, match="No matching signature found"): + verify_webhook("payload", secret, "é", "1744018920", 0) + + +def test_verify_webhook_accepts_a_valid_signature_after_a_non_ascii_one(): + secret = "Ik4L-8FH0ihWczctcIPXZRR_8F0fPNgmhEfVBbZ3zNwqQVa1Or4tBz4Pgw2eNaVDod7H56Y268h_wohEUaWbUg" + signature_header = "é,78aca0c78005107a654a957b8566fa6e0e5e06aea92d7da72a6da9e5a690d013" + + verify_webhook("payload", secret, signature_header, "1744018920", 0)