Skip to content

Commit

Permalink
feature #54525 [Mailer] [Resend] Add Resend webhook signature verific…
Browse files Browse the repository at this point in the history
…ation (welcoMattic)

This PR was squashed before being merged into the 7.1 branch.

Discussion
----------

[Mailer] [Resend] Add Resend webhook signature verification

| Q             | A
| ------------- | ---
| Branch?       | 7.1
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Issues        | Fix #53554 <!-- prefix each issue number with "Fix #", no need to create an issue if none exists, explain below instead -->
| License       | MIT

Follow up of #53554. At this time I missed webhook signature verification. To complete the Bridge before 7.1 release, here it is!

I plan to add more webhook payloads in test, I asked Resend to send me example, because some are tough to reproduce.

Commits
-------

8daa804 [Mailer] [Resend] Add Resend webhook signature verification
  • Loading branch information
fabpot committed Apr 17, 2024
2 parents 22cbf8f + 8daa804 commit 9ec8b7c
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Resend\Tests;
namespace Symfony\Component\Mailer\Bridge\Resend\Tests\Transport;

use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Resend\Tests;
namespace Symfony\Component\Mailer\Bridge\Resend\Tests\Transport;

use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"created_at": "2024-04-08T09:43:09.500Z",
"data": {
"created_at": "2024-04-08T09:43:09.438Z",
"email_id": "172c41ce-ba6d-4281-8a7a-541faa725748",
"from": "test@resend.com",
"headers": [
{
"name": "Sender",
"value": "test@resend.com"
}
],
"subject": "Test subject",
"to": [
"test@example.com"
]
},
"type": "email.sent"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;

$wh = new MailerDeliveryEvent(MailerDeliveryEvent::RECEIVED, '172c41ce-ba6d-4281-8a7a-541faa725748', json_decode(file_get_contents(str_replace('.php', '.json', __FILE__)), true));
$wh->setRecipientEmail('test@example.com');
$wh->setTags([]);
$wh->setMetadata([
'created_at' => '2024-04-08T09:43:09.438Z',
'email_id' => '172c41ce-ba6d-4281-8a7a-541faa725748',
'from' => 'test@resend.com',
'headers' => [
[
'name' => 'Sender',
'value' => 'test@resend.com'
],
],
'subject' => 'Test subject',
'to' => [
'test@example.com',
],
]);
$wh->setReason('');
$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.u\Z', '2024-04-08T09:43:09.500000Z'));

return $wh;
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Resend\Tests\Webhook;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter;
use Symfony\Component\Mailer\Bridge\Resend\Webhook\ResendRequestParser;
use Symfony\Component\Webhook\Client\RequestParserInterface;
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;

class ResendRequestParserTest extends AbstractRequestParserTestCase
{
protected function createRequestParser(): RequestParserInterface
{
return new ResendRequestParser(new ResendPayloadConverter());
}

protected function getSecret(): string
{
return 'whsec_ESwTAuuIe3yfH4DgdgI+ENsiNzPAGdp+';
}

protected function createRequest(string $payload): Request
{
return Request::create('/', 'POST', [], [], [], [
'Content-Type' => 'application/json',
'HTTP_svix-id' => '172c41ce-ba6d-4281-8a7a-541faa725748',
'HTTP_svix-timestamp' => '1712569389',
'HTTP_svix-signature' => 'v1,4wjuRp64yC/2itgCQwl2xPePVwSPTdPbXLIY6IxGLTA=',
], str_replace("\n", "\r\n", $payload));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
namespace Symfony\Component\Mailer\Bridge\Resend\Webhook;

use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcher\HeaderRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter;
use Symfony\Component\Mailer\Exception\InvalidArgumentException;
use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent;
use Symfony\Component\RemoteEvent\Exception\ParseException;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
Expand All @@ -34,14 +36,23 @@ protected function getRequestMatcher(): RequestMatcherInterface
{
return new ChainRequestMatcher([
new MethodRequestMatcher('POST'),
new SchemeRequestMatcher('https'),
new IsJsonRequestMatcher(),
new HeaderRequestMatcher([
'svix-id',
'svix-timestamp',
'svix-signature',
]),
]);
}

protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?AbstractMailerEvent
{
if (!$secret) {
throw new InvalidArgumentException('A non-empty secret is required.');
}

$content = $request->toArray();

if (
!isset($content['type'])
|| !isset($content['created_at'])
Expand All @@ -55,10 +66,65 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr
throw new RejectWebhookException(406, 'Payload is malformed.');
}

$this->validateSignature($request->getContent(), $request->headers, $secret);

try {
return $this->converter->convert($content);
} catch (ParseException $e) {
throw new RejectWebhookException(406, $e->getMessage(), $e);
}
}

private function validateSignature(string $payload, HeaderBag $headers, string $secret): void
{
$secret = $this->decodeSecret($secret);
$messageId = $headers->get('svix-id');
$messageTimestamp = (int) $headers->get('svix-timestamp');
$messageSignature = $headers->get('svix-signature');

$signature = $this->sign($secret, $messageId, $messageTimestamp, $payload);
$expectedSignature = explode(',', $signature, 2)[1];
$passedSignatures = explode(' ', $messageSignature);
$signatureFound = false;

foreach ($passedSignatures as $versionedSignature) {
$signatureParts = explode(',', $versionedSignature, 2);
$version = $signatureParts[0];

if ('v1' !== $version) {
continue;
}

$passedSignature = $signatureParts[1];

if (hash_equals($expectedSignature, $passedSignature)) {
$signatureFound = true;

break;
}
}

if (!$signatureFound) {
throw new RejectWebhookException(406, 'No signatures found matching the expected signature.');
}
}

private function sign(string $secret, string $messageId, int $timestamp, string $payload): string
{
$toSign = sprintf('%s.%s.%s', $messageId, $timestamp, $payload);
$hash = hash_hmac('sha256', $toSign, $secret);
$signature = base64_encode(pack('H*', $hash));

return 'v1,'.$signature;
}

private function decodeSecret(string $secret): string
{
$prefix = 'whsec_';
if (str_starts_with($secret, $prefix)) {
$secret = substr($secret, \strlen($prefix));
}

return base64_decode($secret);
}
}

0 comments on commit 9ec8b7c

Please sign in to comment.