Skip to content

MT-22022: Add webhook signature verification helper#67

Merged
Rabsztok merged 3 commits into
mainfrom
MT-22022-webhook-signature-verification
May 27, 2026
Merged

MT-22022: Add webhook signature verification helper#67
Rabsztok merged 3 commits into
mainfrom
MT-22022-webhook-signature-verification

Conversation

@Rabsztok
Copy link
Copy Markdown
Contributor

@Rabsztok Rabsztok commented May 20, 2026

Motivation

Expose a helper so Python users don't have to re-implement Mailtrap's HMAC-SHA256 webhook signature check on every receiver.

Changes

  • mailtrap.webhooks.verify_signature(payload, signature, signing_secret)True/False. Re-exported as mailtrap.verify_signature for mt.verify_signature(...) usage. HMAC-SHA256 over the raw body, constant-time compare via hmac.compare_digest. Accepts payload as str (UTF-8) or bytes. Returns False (never raises) on empty / wrong-length / non-hex / wrong-type inputs.
  • tests/unit/test_webhook_signature.py pins the cross-SDK fixture (payload + signing_secret + expected digest) shared verbatim across all six official Mailtrap SDKs to guarantee byte-for-byte parity.
  • examples/webhooks/verify_signature.py — runnable usage snippet.
  • README — new "Verifying webhook signatures" subsection.

How to test

CI runs pytest, mypy, and pre-commit. Manually:

python examples/webhooks/verify_signature.py

The script should exit 0 with no output.

Companion PRs

Coordinated rollout across all six official SDKs (same algorithm, same shared fixture):

Summary by CodeRabbit

  • New Features

    • Webhook signature verification capability added to the SDK.
  • Documentation

    • Updated README with webhook verification examples.
    • Added example WSGI server implementation for webhook handling.
  • Tests

    • Comprehensive test suite for webhook signature verification added.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

📝 Walkthrough

Walkthrough

Adds HMAC-SHA256 webhook signature verification (mailtrap.webhooks.verify_signature), re-exports it at package top-level, provides a WSGI example that verifies incoming requests, updates README examples, and adds comprehensive unit tests.

Changes

Webhook signature verification

Layer / File(s) Summary
Core verification implementation
mailtrap/webhooks.py
Implements verify_signature(payload, signature, signing_secret) with input validation, UTF-8 payload encoding, HMAC-SHA256 hex digest computation, exception-to-False handling, and constant-time comparison via hmac.compare_digest.
SDK module export
mailtrap/__init__.py
Re-exports verify_signature from .webhooks at the package top level.
Test suite and fixtures
tests/unit/test_webhook_signature.py
Adds unit tests with shared fixtures covering correct verification, wrong secret, tampering, malformed/too-short/non-hex/empty signatures, empty signing secret/payload, regression HMAC digest check, and bytes-payload support.
Documentation and examples
README.md, examples/webhooks/verify_signature.py
README adds a "Verifying webhook signatures" link; example provides a WSGI server that reads raw request body, extracts HTTP_MAILTRAP_SIGNATURE, verifies via mailtrap.verify_signature, and responds 401/200.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I nibble secrets, check each line,
HMAC threads woven, steady and fine.
Bytes and hex dance in careful light,
Signatures guarded through day and night.
Hop, verify, and all is right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a webhook signature verification helper to the SDK.
Description check ✅ Passed The description is comprehensive and well-structured, covering motivation, detailed changes, testing instructions, and companion PRs across all official SDKs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch MT-22022-webhook-signature-verification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Rabsztok Rabsztok force-pushed the MT-22022-webhook-signature-verification branch from 35b2788 to 129bc81 Compare May 20, 2026 10:58
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
README.md (1)

249-274: 💤 Low value

Clear and accurate webhook signature documentation.

The explanation of HMAC-SHA256 verification is well-written, and the important warning about using raw request body bytes (not re-serialized JSON) is prominently placed.

Consider adding a brief note about framework-specific approaches for obtaining the raw request body, e.g.:

**Framework examples:**
- Flask: `raw_body = request.get_data()`
- FastAPI: `raw_body = await request.body()`
- Django: `raw_body = request.body`

This would help users who are unfamiliar with accessing raw request bodies in their chosen framework, though the current framework-agnostic approach is also valid.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 249 - 274, Add a short framework-specific note under
the "Verifying webhook signatures" section to show how to obtain the raw request
body for common Python frameworks (e.g., Flask, FastAPI, Django) so users can
correctly pass raw_body to mt.verify_signature; reference the Mailtrap-Signature
header and the signing_secret usage, and keep the note concise and clearly
labeled (e.g., "Framework examples") to avoid changing the existing verification
guidance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@README.md`:
- Around line 249-274: Add a short framework-specific note under the "Verifying
webhook signatures" section to show how to obtain the raw request body for
common Python frameworks (e.g., Flask, FastAPI, Django) so users can correctly
pass raw_body to mt.verify_signature; reference the Mailtrap-Signature header
and the signing_secret usage, and keep the note concise and clearly labeled
(e.g., "Framework examples") to avoid changing the existing verification
guidance.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99d05986-8255-4a01-bbc5-c16791711838

📥 Commits

Reviewing files that changed from the base of the PR and between 294d573 and 129bc81.

📒 Files selected for processing (5)
  • README.md
  • examples/webhooks/verify_signature.py
  • mailtrap/__init__.py
  • mailtrap/webhooks.py
  • tests/unit/test_webhook_signature.py

Add `mailtrap.webhooks.verify_signature` (re-exported as `mt.verify_signature`)
to verify Mailtrap webhook signatures using HMAC-SHA256 over the raw request
body with constant-time hex comparison via `hmac.compare_digest`.

Returns False (no raise) for missing/empty/malformed/wrong-length signatures
so a single guard at the request handler covers every bad-input case. Accepts
both `str` and `bytes` payloads; signed input must be the raw body bytes (the
helper docstring and example warn against re-serializing parsed JSON).

Includes the shared cross-SDK test fixture (payload + secret + expected
signature) that all six Mailtrap SDKs use to stay byte-for-byte compatible,
plus a Flask receiver example and README subsection.

See https://railsware.atlassian.net/browse/MT-22022
@Rabsztok Rabsztok force-pushed the MT-22022-webhook-signature-verification branch from 129bc81 to f2f05dc Compare May 20, 2026 14:19
@Rabsztok
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/unit/test_webhook_signature.py (2)

24-24: ⚡ Quick win

Suppress secret-scanner false positives for the shared fixture.

Line 24 is an intentional cross-SDK test fixture, but it will keep tripping leak/password detectors. Add an inline suppression note so CI/security scans stay actionable.

Suggested patch
-FIXTURE_SIGNING_SECRET = "8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e"
+FIXTURE_SIGNING_SECRET = "8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e"  # noqa: S105 - shared non-production test fixture
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_webhook_signature.py` at line 24, The test constant
FIXTURE_SIGNING_SECRET in tests/unit/test_webhook_signature.py is an intentional
cross-SDK fixture that triggers secret scanners; update the declaration to
include an inline suppression comment explaining it's a non-sensitive test
fixture and add tool-specific suppressions (for example append a short note plus
common suppressors like "# noqa: S105" and/or "# pragma: nosec") so CI/security
scanners ignore this known test value while keeping the explanatory comment next
to FIXTURE_SIGNING_SECRET.

30-126: ⚡ Quick win

Add explicit wrong-type input tests for contract lock-in.

verify_signature guarantees False (not raise) for wrong types; add direct tests for invalid payload/signature/secret types to prevent regressions.

Suggested test additions
+    def test_returns_false_for_wrong_input_types(self) -> None:
+        assert verify_signature(123, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET) is False
+        assert verify_signature(FIXTURE_PAYLOAD, 123, FIXTURE_SIGNING_SECRET) is False
+        assert verify_signature(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, 123) is False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_webhook_signature.py` around lines 30 - 126, Add tests to
TestVerifySignature that pass invalid types to verify_signature and assert it
returns False (not raise) to lock the contract: include cases such as payload as
an int and as None, signature as bytes, int, and a list, and signing_secret as
None and as a dict; use the existing FIXTURE_EXPECTED_SIGNATURE,
FIXTURE_SIGNING_SECRET, FIXTURE_PAYLOAD and SIGNATURE_HEX_LENGTH symbols to
construct inputs and assertions so failures point to verify_signature.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/webhooks/verify_signature.py`:
- Around line 13-16: The code currently reads and decodes the request body into
a UTF-8 string before verification which can raise UnicodeDecodeError and
violates verify_signature's expectation of raw bytes; change the logic that
reads environ["wsgi.input"] into the variable payload so it remains bytes (do
not call .decode("utf-8")) and pass those bytes directly to
mt.verify_signature(payload, signature, SIGNING_SECRET) so the signature
function receives the exact raw request body.

---

Nitpick comments:
In `@tests/unit/test_webhook_signature.py`:
- Line 24: The test constant FIXTURE_SIGNING_SECRET in
tests/unit/test_webhook_signature.py is an intentional cross-SDK fixture that
triggers secret scanners; update the declaration to include an inline
suppression comment explaining it's a non-sensitive test fixture and add
tool-specific suppressions (for example append a short note plus common
suppressors like "# noqa: S105" and/or "# pragma: nosec") so CI/security
scanners ignore this known test value while keeping the explanatory comment next
to FIXTURE_SIGNING_SECRET.
- Around line 30-126: Add tests to TestVerifySignature that pass invalid types
to verify_signature and assert it returns False (not raise) to lock the
contract: include cases such as payload as an int and as None, signature as
bytes, int, and a list, and signing_secret as None and as a dict; use the
existing FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET, FIXTURE_PAYLOAD and
SIGNATURE_HEX_LENGTH symbols to construct inputs and assertions so failures
point to verify_signature.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aad07a05-0968-4832-bf3c-d240b48385db

📥 Commits

Reviewing files that changed from the base of the PR and between 129bc81 and 8d3faca.

📒 Files selected for processing (5)
  • README.md
  • examples/webhooks/verify_signature.py
  • mailtrap/__init__.py
  • mailtrap/webhooks.py
  • tests/unit/test_webhook_signature.py
✅ Files skipped from review due to trivial changes (1)
  • README.md

Comment on lines +13 to +16
payload = environ["wsgi.input"].read(length).decode("utf-8")
signature = environ.get("HTTP_MAILTRAP_SIGNATURE", "")

if not mt.verify_signature(payload, signature, SIGNING_SECRET):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Python 3, does bytes.decode("utf-8") raise UnicodeDecodeError on invalid UTF-8 by default?

💡 Result:

Yes, in Python 3, bytes.decode("utf-8") raises a UnicodeDecodeError by default when it encounters invalid UTF-8 sequences [1][2]. This occurs because the default value for the errors argument in the bytes.decode method is 'strict' [1][2]. The 'strict' error handler is defined to raise a UnicodeError (specifically a UnicodeDecodeError during decoding) whenever an encoding or decoding error is encountered [2][3][4]. If you wish to change this behavior, you can provide a different error handler for the errors argument, such as: - 'ignore': Discards the malformed bytes [3]. - 'replace': Inserts the official Unicode replacement character (U+FFFD) for malformed bytes [3][5]. Example: # This raises UnicodeDecodeError b'\xff'.decode('utf-8') # This returns '\ufffd' b'\xff'.decode('utf-8', errors='replace')

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant snippet in the example file with surrounding context
sed -n '1,120p' examples/webhooks/verify_signature.py | nl -ba

# Find where verify_signature is defined/used in the repo
rg -n "def verify_signature|verify_signature\(" -S . | head -n 50

Repository: mailtrap/mailtrap-python

Length of output: 111


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Print the example file with line numbers (no nl)
cat -n examples/webhooks/verify_signature.py | sed -n '1,160p'

# Find verify_signature definitions/usages
rg -n "def verify_signature|verify_signature\(" -S . | head -n 80

Repository: mailtrap/mailtrap-python

Length of output: 2293


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect verify_signature implementation
cat -n mailtrap/webhooks.py | sed -n '1,200p'

# Inspect unit tests around webhook signature verification
cat -n tests/unit/test_webhook_signature.py | sed -n '1,220p'

Repository: mailtrap/mailtrap-python

Length of output: 8903


Use raw request body bytes for signature verification (remove UTF-8 decoding).

examples/webhooks/verify_signature.py decodes environ["wsgi.input"] with .decode("utf-8") before calling mt.verify_signature. With Python’s default errors="strict", this can raise UnicodeDecodeError on non-UTF-8 payloads, whereas verify_signature is designed to handle inputs safely; also verify_signature expects the raw request body “exactly as received” and accepts bytes directly.

Suggested patch
-    payload = environ["wsgi.input"].read(length).decode("utf-8")
+    payload = environ["wsgi.input"].read(length)
     signature = environ.get("HTTP_MAILTRAP_SIGNATURE", "")
 
     if not mt.verify_signature(payload, signature, SIGNING_SECRET):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
payload = environ["wsgi.input"].read(length).decode("utf-8")
signature = environ.get("HTTP_MAILTRAP_SIGNATURE", "")
if not mt.verify_signature(payload, signature, SIGNING_SECRET):
payload = environ["wsgi.input"].read(length)
signature = environ.get("HTTP_MAILTRAP_SIGNATURE", "")
if not mt.verify_signature(payload, signature, SIGNING_SECRET):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/webhooks/verify_signature.py` around lines 13 - 16, The code
currently reads and decodes the request body into a UTF-8 string before
verification which can raise UnicodeDecodeError and violates verify_signature's
expectation of raw bytes; change the logic that reads environ["wsgi.input"] into
the variable payload so it remains bytes (do not call .decode("utf-8")) and pass
those bytes directly to mt.verify_signature(payload, signature, SIGNING_SECRET)
so the signature function receives the exact raw request body.

@Rabsztok Rabsztok requested review from IgorDobryn and mklocek May 26, 2026 07:47
@Rabsztok Rabsztok merged commit ab72eaa into main May 27, 2026
16 checks passed
@Rabsztok Rabsztok deleted the MT-22022-webhook-signature-verification branch May 27, 2026 05:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants