From d109af965bcc81d56426f6c7cba6eec205db2a4d Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:29:04 +0000 Subject: [PATCH 1/3] fix(webhooks): raise ValueError for non-ASCII signature in Webhooks.verify --- resend/webhooks/_webhooks.py | 6 +++++- tests/webhooks_test.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index 3a64cb3..8dc601b 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -603,7 +603,11 @@ def verify(cls, options: VerifyWebhookOptions) -> WebhookEventPayload: continue received_signature = parts[1] - if hmac.compare_digest(expected_signature, received_signature): + # Compare bytes: str comparison raises TypeError on non-ASCII input + if hmac.compare_digest( + expected_signature.encode("utf-8"), + received_signature.encode("utf-8", "replace"), + ): try: return cast(WebhookEventPayload, json.loads(options["payload"])) except json.JSONDecodeError as e: diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 30fc0b9..efc01ff 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -309,6 +309,23 @@ def test_verify_invalid_signature(self) -> None: with pytest.raises(ValueError, match="no matching signature found"): resend.Webhooks.verify(options) + def test_verify_non_ascii_signature(self) -> None: + """Test webhook verification rejects a non-ASCII signature with ValueError""" + secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") + + options: resend.VerifyWebhookOptions = { + "payload": '{"type":"email.sent","data":{"email_id":"123"}}', + "headers": { + "id": "msg_123", + "timestamp": str(int(time.time())), + "signature": "v1,\u00e9\u00e8", + }, + "webhook_secret": secret, + } + + with pytest.raises(ValueError, match="no matching signature found"): + resend.Webhooks.verify(options) + def test_verify_expired_timestamp(self) -> None: """Test webhook verification with expired timestamp""" secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") From 447e9ad5dabaae58b74d281a864d051700b5e170 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 2 Oct 2026 21:58:08 -0300 Subject: [PATCH 2/3] test(webhooks): cover unpaired surrogate in Webhooks.verify signature Co-Authored-By: Claude Opus 5.5 --- tests/webhooks_test.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index efc01ff..c53dde3 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -326,6 +326,24 @@ def test_verify_non_ascii_signature(self) -> None: with pytest.raises(ValueError, match="no matching signature found"): resend.Webhooks.verify(options) + def test_verify_unpaired_surrogate_signature(self) -> None: + """Test webhook verification rejects a signature with an unpaired surrogate with ValueError""" + secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") + + options: resend.VerifyWebhookOptions = { + "payload": '{"type":"email.sent","data":{"email_id":"123"}}', + "headers": { + "id": "msg_123", + "timestamp": str(int(time.time())), + # A raw 0xE9 header byte decoded with errors="surrogateescape" + "signature": "v1,\udce9", + }, + "webhook_secret": secret, + } + + with pytest.raises(ValueError, match="no matching signature found"): + resend.Webhooks.verify(options) + def test_verify_expired_timestamp(self) -> None: """Test webhook verification with expired timestamp""" secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") From e514de03a38c33aba7abf7ddb07e2230989d67b6 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 2 Oct 2026 21:58:08 -0300 Subject: [PATCH 3/3] chore: bump version to 2.49.1 Co-Authored-By: Claude Opus 5.5 --- resend/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resend/version.py b/resend/version.py index 9ada10a..4ccbcac 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.49.0" +__version__ = "2.49.1" def get_version() -> str: