From 95a12189613fd5dbec401b774aedc0d36e9a9271 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Wed, 20 May 2026 09:20:45 +0200 Subject: [PATCH 1/2] MT-22022: Add webhook signature verification helper Add `Mailtrap\Helper\WebhookSignature::verify()` to verify Mailtrap webhook signatures using HMAC-SHA256 over the raw request body with constant-time hex comparison via `hash_equals`. Returns false (no throw) for missing/empty/malformed/wrong-length signatures so a single guard at the request handler covers every bad-input case. 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 runnable receiver example and README subsection. See https://railsware.atlassian.net/browse/MT-22022 --- README.md | 24 ++++ examples/webhooks/verify_signature.php | 18 +++ src/Helper/WebhookSignature.php | 61 ++++++++++ tests/Helper/WebhookSignatureTest.php | 150 +++++++++++++++++++++++++ 4 files changed, 253 insertions(+) create mode 100644 examples/webhooks/verify_signature.php create mode 100644 src/Helper/WebhookSignature.php create mode 100644 tests/Helper/WebhookSignatureTest.php diff --git a/README.md b/README.md index 3339368..15502e4 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,30 @@ Framework-specific (quick starts): See the full indexed list at [`examples/README.md`](examples/README.md). +### Verifying webhook signatures + +Mailtrap signs every outbound webhook with HMAC-SHA256 and sends the lowercase hex digest in the `Mailtrap-Signature` header. Verify the signature against the raw request body using the `signing_secret` returned when you created the webhook: + +```php +use Mailtrap\Helper\WebhookSignature; + +// $payload must be the unparsed request body bytes — do NOT re-serialize +// the parsed JSON, as that may reorder keys and invalidate the signature. +$rawBody = file_get_contents('php://input'); +$valid = WebhookSignature::verify( + $rawBody !== false ? $rawBody : '', + $_SERVER['HTTP_MAILTRAP_SIGNATURE'] ?? '', + $_ENV['MAILTRAP_WEBHOOK_SIGNING_SECRET'] ?? '' +); + +if (!$valid) { + http_response_code(401); + exit; +} +``` + +The helper performs a constant-time comparison and returns `false` (rather than raising) for empty, missing, or malformed signatures. + ## Contributing Bug reports and pull requests are welcome on [GitHub](https://github.com/railsware/mailtrap-php). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/examples/webhooks/verify_signature.php b/examples/webhooks/verify_signature.php new file mode 100644 index 0000000..cad186a --- /dev/null +++ b/examples/webhooks/verify_signature.php @@ -0,0 +1,18 @@ +assertTrue( + WebhookSignature::verify( + self::FIXTURE_PAYLOAD, + self::FIXTURE_EXPECTED_SIGNATURE, + self::FIXTURE_SIGNING_SECRET + ) + ); + } + + // --- 2. Wrong secret --------------------------------------------------- + public function testReturnsFalseWithWrongSigningSecret(): void + { + $this->assertFalse( + WebhookSignature::verify( + self::FIXTURE_PAYLOAD, + self::FIXTURE_EXPECTED_SIGNATURE, + 'ffffffffffffffffffffffffffffffff' + ) + ); + } + + // --- 3. Payload tampered (one byte changed) ---------------------------- + public function testReturnsFalseWhenPayloadIsTampered(): void + { + $tampered = str_replace('delivery', 'Delivery', self::FIXTURE_PAYLOAD); + + $this->assertFalse( + WebhookSignature::verify( + $tampered, + self::FIXTURE_EXPECTED_SIGNATURE, + self::FIXTURE_SIGNING_SECRET + ) + ); + } + + // --- 4. Signature with wrong length ------------------------------------ + public function testReturnsFalseWithoutRaisingWhenSignatureTooShort(): void + { + $tooShort = substr(self::FIXTURE_EXPECTED_SIGNATURE, 0, 31); + + $this->assertFalse( + WebhookSignature::verify( + self::FIXTURE_PAYLOAD, + $tooShort, + self::FIXTURE_SIGNING_SECRET + ) + ); + } + + // --- 5. Signature with non-hex characters ------------------------------ + public function testReturnsFalseWithoutRaisingForNonHexSignature(): void + { + $notHex = str_repeat('z', WebhookSignature::SIGNATURE_HEX_LENGTH); + + $this->assertFalse( + WebhookSignature::verify( + self::FIXTURE_PAYLOAD, + $notHex, + self::FIXTURE_SIGNING_SECRET + ) + ); + } + + // --- 6. Empty signature string ----------------------------------------- + public function testReturnsFalseForEmptySignature(): void + { + $this->assertFalse( + WebhookSignature::verify( + self::FIXTURE_PAYLOAD, + '', + self::FIXTURE_SIGNING_SECRET + ) + ); + } + + // --- 7. Empty signing_secret ------------------------------------------- + public function testReturnsFalseForEmptySigningSecret(): void + { + $this->assertFalse( + WebhookSignature::verify( + self::FIXTURE_PAYLOAD, + self::FIXTURE_EXPECTED_SIGNATURE, + '' + ) + ); + } + + // --- 8. Empty payload + non-empty signature ---------------------------- + public function testReturnsFalseForEmptyPayload(): void + { + $this->assertFalse( + WebhookSignature::verify( + '', + self::FIXTURE_EXPECTED_SIGNATURE, + self::FIXTURE_SIGNING_SECRET + ) + ); + } + + // --- 9. Known-good cross-SDK fixture ----------------------------------- + public function testMatchesHardcodedHmacSha256DigestForSharedFixture(): void + { + // Recompute the digest in-place so a regression in PHP's hash + // extension or the fixture itself fails loudly: this is the + // byte-for-byte contract every other Mailtrap SDK must satisfy. + $computed = hash_hmac('sha256', self::FIXTURE_PAYLOAD, self::FIXTURE_SIGNING_SECRET); + + $this->assertSame(self::FIXTURE_EXPECTED_SIGNATURE, $computed); + $this->assertTrue( + WebhookSignature::verify( + self::FIXTURE_PAYLOAD, + self::FIXTURE_EXPECTED_SIGNATURE, + self::FIXTURE_SIGNING_SECRET + ) + ); + } +} From 8a2cfd44fd8a25670fab737c48e6507ec240a04d Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Thu, 21 May 2026 09:47:20 +0200 Subject: [PATCH 2/2] MT-22022: Simplify example to happy-path verification only --- examples/webhooks/verify_signature.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/webhooks/verify_signature.php b/examples/webhooks/verify_signature.php index cad186a..3355910 100644 --- a/examples/webhooks/verify_signature.php +++ b/examples/webhooks/verify_signature.php @@ -11,8 +11,7 @@ $signingSecret = '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e'; $signature = hash_hmac('sha256', $payload, $signingSecret); -assert(WebhookSignature::verify($payload, $signature, $signingSecret) === true); - -// Bad input never raises — it returns false: -assert(WebhookSignature::verify($payload, 'not-hex', $signingSecret) === false); -assert(WebhookSignature::verify($payload, '', $signingSecret) === false); +if (!WebhookSignature::verify($payload, $signature, $signingSecret)) { + fwrite(STDERR, "Signature verification failed!\n"); + exit(1); +}