Skip to content

Commit ab72eaa

Browse files
authored
Merge pull request #67 from mailtrap/MT-22022-webhook-signature-verification
MT-22022: Add webhook signature verification helper
2 parents 294d573 + 8d3faca commit ab72eaa

5 files changed

Lines changed: 225 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ The same situation applies to both `client.batch_send()` and `client.sending_api
246246

247247
### Webhooks API:
248248
- Webhooks management – [`webhooks/webhooks.py`](examples/webhooks/webhooks.py)
249+
- Verifying webhook signatures – [`webhooks/verify_signature.py`](examples/webhooks/verify_signature.py)
249250

250251
### Suppressions API:
251252
- Suppressions (find & delete) – [`suppressions/suppressions.py`](examples/suppressions/suppressions.py)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import os
2+
from wsgiref.simple_server import make_server
3+
4+
import mailtrap as mt
5+
6+
SIGNING_SECRET = os.environ["MAILTRAP_WEBHOOK_SIGNING_SECRET"]
7+
8+
9+
def app(environ, start_response):
10+
# Use the raw request body — parsing and re-serializing the JSON may
11+
# reorder keys or alter whitespace and invalidate the signature.
12+
length = int(environ.get("CONTENT_LENGTH") or 0)
13+
payload = environ["wsgi.input"].read(length).decode("utf-8")
14+
signature = environ.get("HTTP_MAILTRAP_SIGNATURE", "")
15+
16+
if not mt.verify_signature(payload, signature, SIGNING_SECRET):
17+
start_response("401 Unauthorized", [("Content-Type", "text/plain")])
18+
return [b"Invalid signature"]
19+
20+
start_response("200 OK", [("Content-Type", "text/plain")])
21+
return [b""]
22+
23+
24+
if __name__ == "__main__":
25+
with make_server("", 9292, app) as server:
26+
server.serve_forever()

mailtrap/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@
4343
from .models.templates import UpdateEmailTemplateParams
4444
from .models.webhooks import CreateWebhookParams
4545
from .models.webhooks import UpdateWebhookParams
46+
from .webhooks import verify_signature

mailtrap/webhooks.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Helpers for working with inbound Mailtrap webhooks.
2+
3+
See https://docs.mailtrap.io/email-api-smtp/advanced/webhooks#verifying-the-signature
4+
for the algorithm reference.
5+
"""
6+
7+
import hashlib
8+
import hmac
9+
from typing import Union
10+
11+
# Hex-encoded HMAC-SHA256 signature length (SHA-256 produces 32 bytes / 64 hex chars).
12+
SIGNATURE_HEX_LENGTH = 64
13+
14+
15+
def verify_signature(
16+
payload: Union[str, bytes],
17+
signature: str,
18+
signing_secret: str,
19+
) -> bool:
20+
"""Verify the HMAC-SHA256 signature of a Mailtrap webhook payload.
21+
22+
Mailtrap signs every outbound webhook by computing
23+
``HMAC-SHA256(signing_secret, raw_request_body)`` and sending the
24+
lowercase hex digest in the ``Mailtrap-Signature`` HTTP header. Compute
25+
the same digest on your side and compare it in constant time.
26+
27+
The comparison is performed with :func:`hmac.compare_digest` to avoid
28+
timing side-channels.
29+
30+
The function never raises on inputs that could plausibly arrive over the
31+
wire (empty strings, wrong-length signatures, non-hex characters, missing
32+
secret) -- it simply returns ``False``. This makes it safe to call
33+
directly from a request handler without wrapping in ``try``/``except``.
34+
35+
:param payload: The raw request body, exactly as received. Accepts
36+
``str`` (encoded as UTF-8 internally) or ``bytes``. **Do not** parse
37+
and re-serialize the JSON -- re-encoding may reorder keys or alter
38+
whitespace and invalidate the signature.
39+
:param signature: The value of the ``Mailtrap-Signature`` HTTP header
40+
(lowercase hex string).
41+
:param signing_secret: The webhook's ``signing_secret``, returned by
42+
:meth:`mailtrap.api.resources.webhooks.WebhooksApi.create` on
43+
webhook creation.
44+
:returns: ``True`` if the signature is valid for the given payload and
45+
secret, ``False`` otherwise.
46+
"""
47+
if not isinstance(signature, str) or not signature:
48+
return False
49+
if not isinstance(signing_secret, str) or not signing_secret:
50+
return False
51+
if not isinstance(payload, (str, bytes)):
52+
return False
53+
if len(payload) == 0:
54+
return False
55+
if len(signature) != SIGNATURE_HEX_LENGTH:
56+
return False
57+
58+
if isinstance(payload, str):
59+
payload_bytes = payload.encode("utf-8")
60+
else:
61+
payload_bytes = payload
62+
63+
try:
64+
expected = hmac.new(
65+
signing_secret.encode("utf-8"),
66+
payload_bytes,
67+
hashlib.sha256,
68+
).hexdigest()
69+
except (TypeError, ValueError):
70+
return False
71+
72+
return hmac.compare_digest(expected, signature)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import hashlib
2+
import hmac
3+
4+
from mailtrap.webhooks import SIGNATURE_HEX_LENGTH
5+
from mailtrap.webhooks import verify_signature
6+
7+
# ---------------------------------------------------------------------------
8+
# Cross-SDK fixture
9+
#
10+
# The (payload, signing_secret, expected_signature) triple below is the
11+
# canonical fixture shared verbatim by every official Mailtrap SDK
12+
# (mailtrap-ruby, mailtrap-python, mailtrap-php, mailtrap-nodejs,
13+
# mailtrap-java, mailtrap-dotnet). Any change here MUST be mirrored in the
14+
# equivalent test files in the other SDKs so the helpers stay byte-for-byte
15+
# compatible across languages.
16+
# ---------------------------------------------------------------------------
17+
FIXTURE_PAYLOAD = (
18+
'{"event":"delivery","sending_stream":"transactional","category":"welcome",'
19+
'"message_id":"a8b1d8f6-1f8d-4a3c-9b2e-1a2b3c4d5e6f",'
20+
'"email":"recipient@example.com",'
21+
'"event_id":"f1e2d3c4-b5a6-7890-1234-567890abcdef",'
22+
'"timestamp":1716070000}'
23+
)
24+
FIXTURE_SIGNING_SECRET = "8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e"
25+
FIXTURE_EXPECTED_SIGNATURE = (
26+
"6d262e2611cd09be1f948382b5c611d63b0e585c4c9c5e40139d6ac3876d5433"
27+
)
28+
29+
30+
class TestVerifySignature:
31+
# --- 1. Valid signature for given payload + secret ----------------------
32+
def test_returns_true_for_valid_signature_payload_and_secret(self) -> None:
33+
assert (
34+
verify_signature(
35+
FIXTURE_PAYLOAD,
36+
FIXTURE_EXPECTED_SIGNATURE,
37+
FIXTURE_SIGNING_SECRET,
38+
)
39+
is True
40+
)
41+
42+
# --- 2. Wrong secret ----------------------------------------------------
43+
def test_returns_false_with_wrong_signing_secret(self) -> None:
44+
assert (
45+
verify_signature(
46+
FIXTURE_PAYLOAD,
47+
FIXTURE_EXPECTED_SIGNATURE,
48+
"ffffffffffffffffffffffffffffffff",
49+
)
50+
is False
51+
)
52+
53+
# --- 3. Payload tampered (one byte changed) -----------------------------
54+
def test_returns_false_when_payload_is_tampered(self) -> None:
55+
tampered = FIXTURE_PAYLOAD.replace("delivery", "Delivery")
56+
57+
assert (
58+
verify_signature(
59+
tampered,
60+
FIXTURE_EXPECTED_SIGNATURE,
61+
FIXTURE_SIGNING_SECRET,
62+
)
63+
is False
64+
)
65+
66+
# --- 4. Signature with wrong length -------------------------------------
67+
def test_returns_false_without_raising_when_signature_too_short(self) -> None:
68+
too_short = FIXTURE_EXPECTED_SIGNATURE[:31]
69+
70+
assert (
71+
verify_signature(FIXTURE_PAYLOAD, too_short, FIXTURE_SIGNING_SECRET) is False
72+
)
73+
74+
# --- 5. Signature with non-hex characters -------------------------------
75+
def test_returns_false_without_raising_for_non_hex_signature(self) -> None:
76+
not_hex = "z" * SIGNATURE_HEX_LENGTH
77+
78+
assert verify_signature(FIXTURE_PAYLOAD, not_hex, FIXTURE_SIGNING_SECRET) is False
79+
80+
# --- 6. Empty signature string ------------------------------------------
81+
def test_returns_false_for_empty_signature(self) -> None:
82+
assert verify_signature(FIXTURE_PAYLOAD, "", FIXTURE_SIGNING_SECRET) is False
83+
84+
# --- 7. Empty signing_secret --------------------------------------------
85+
def test_returns_false_for_empty_signing_secret(self) -> None:
86+
assert verify_signature(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, "") is False
87+
88+
# --- 8. Empty payload + non-empty signature -----------------------------
89+
def test_returns_false_for_empty_payload(self) -> None:
90+
assert (
91+
verify_signature("", FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET)
92+
is False
93+
)
94+
95+
# --- 9. Known-good cross-SDK fixture ------------------------------------
96+
def test_matches_hardcoded_hmac_sha256_digest_for_shared_fixture(self) -> None:
97+
# Recompute the digest in-place so a regression in the stdlib or the
98+
# fixture itself fails loudly: this is the byte-for-byte contract
99+
# every other Mailtrap SDK must satisfy.
100+
computed = hmac.new(
101+
FIXTURE_SIGNING_SECRET.encode("utf-8"),
102+
FIXTURE_PAYLOAD.encode("utf-8"),
103+
hashlib.sha256,
104+
).hexdigest()
105+
106+
assert computed == FIXTURE_EXPECTED_SIGNATURE
107+
assert (
108+
verify_signature(
109+
FIXTURE_PAYLOAD,
110+
FIXTURE_EXPECTED_SIGNATURE,
111+
FIXTURE_SIGNING_SECRET,
112+
)
113+
is True
114+
)
115+
116+
# --- Bonus: accepts bytes payload ---------------------------------------
117+
def test_accepts_bytes_payload(self) -> None:
118+
assert (
119+
verify_signature(
120+
FIXTURE_PAYLOAD.encode("utf-8"),
121+
FIXTURE_EXPECTED_SIGNATURE,
122+
FIXTURE_SIGNING_SECRET,
123+
)
124+
is True
125+
)

0 commit comments

Comments
 (0)