10000 feature #54525 [Mailer] [Resend] Add Resend webhook signature verific… · symfony/symfony@9ec8b7c · GitHub
[go: up one dir, main page]

Skip to content

Commit 9ec8b7c

Browse files
committed
feature #54525 [Mailer] [Resend] Add Resend webhook signature verification (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
2 parents 22cbf8f + 8daa804 commit 9ec8b7c

File tree

6 files changed

+156
-4
lines changed

6 files changed

+156
-4
lines changed

src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendApiTransportTest.php renamed to src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendApiTransportTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* file that was distributed with this source code.
1010
*/
1111

12-
namespace Symfony\Component\Mailer\Bridge\Resend\Tests;
12+
namespace Symfony\Component\Mailer\Bridge\Resend\Tests\Transport;
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\HttpClient\MockHttpClient;

src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendTransportFactoryTest.php renamed to src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendTransportFactoryTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* file that was distributed with this source code.
1010
*/
1111

12-
namespace Symfony\Component\Mailer\Bridge\Resend\Tests;
12+
namespace Symfony\Component\Mailer\Bridge\Resend\Tests\Transport;
1313

1414
use Psr\Log\NullLogger;
1515
use Symfony\Component\HttpClient\MockHttpClient;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"created_at": "2024-04-08T09:43:09.500Z",
3+
"data": {
4+
"created_at": "2024-04-08T09:43:09.438Z",
5+
"email_id": "172c41ce-ba6d-4281-8a7a-541faa725748",
6+
"from": "test@resend.com",
7+
"headers": [
8+
{
9+
"name": "Sender",
10+
"value": "test@resend.com"
11+
}
12+
],
13+
"subject": "Test subject",
14+
"to": [
15+
"test@example.com"
16+
]
17+
},
18+
"type": "email.sent"
19+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;
4+
5+
$wh = new MailerDeliveryEvent(MailerDeliveryEvent::RECEIVED, '172c41ce-ba6d-4281-8a7a-541faa725748', json_decode(file_get_contents(str_replace('.php', '.json', __FILE__)), true));
6+
$wh->setRecipientEmail('test@example.com');
7+
$wh->setTags([]);
8+
$wh->setMetadata([
9+
'created_at' => '2024-04-08T09:43:09.438Z',
10+
'email_id' => '172c41ce-ba6d-4281-8a7a-541faa725748',
11+
'from' => 'test@resend.com',
12+
'headers' => [
13+
[
14+
'name' => 'Sender',
15+
'value' => 'test@resend.com'
16+
],
17+
],
18+
'subject' => 'Test subject',
19+
'to' => [
20+
'test@example.com',
21+
],
22+
]);
23+
$wh->setReason('');
24+
$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.u\Z', '2024-04-08T09:43:09.500000Z'));
25+
26+
return $wh;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Mailer\Bridge\Resend\Tests\Webhook;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter;
16+
use Symfony\Component\Mailer\Bridge\Resend\Webhook\ResendRequestParser;
17+
use Symfony\Component\Webhook\Client\RequestParserInterface;
18+
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;
19+
20+
class ResendRequestParserTest extends AbstractRequestParserTestCase
21+
{
22+
protected function createRequestParser(): RequestParserInterface
23+
{
24+
return new ResendRequestParser(new ResendPayloadConverter());
25+
}
26+
27+
protected function getSecret(): string
28+
{
29+
return 'whsec_ESwTAuuIe3yfH4DgdgI+ENsiNzPAGdp+';
30+
}
31+
32+
protected function createRequest(string $payload): Request
33+
{
34+
return Request::create('/', 'POST', [], [], [], [
35+
'Content-Type' => 'application/json',
36+
'HTTP_svix-id' => '172c41ce-ba6d-4281-8a7a-541faa725748',
37+
'HTTP_svix-timestamp' => '1712569389',
38+
'HTTP_svix-signature' => 'v1,4wjuRp64yC/2itgCQwl2xPePVwSPTdPbXLIY6IxGLTA=',
39+
], str_replace("\n", "\r\n", $payload));
40+
}
41+
}

src/Symfony/Component/Mailer/Bridge/Resend/Webhook/ResendRequestParser.php

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
namespace Symfony\Component\Mailer\Bridge\Resend\Webhook;
1313

1414
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
15+
use Symfony\Component\HttpFoundation\HeaderBag;
1516
use Symfony\Component\HttpFoundation\Request;
17+
use Symfony\Component\HttpFoundation\RequestMatcher\HeaderRequestMatcher;
1618
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
1719
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
18-
use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher;
1920
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
2021
use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter;
22+
use Symfony\Component\Mailer\Exception\InvalidArgumentException;
2123
use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent;
2224
use Symfony\Component\RemoteEvent\Exception\ParseException;
2325
use Symfony\Component\Webhook\Client\AbstractRequestParser;
@@ -34,14 +36,23 @@ protected function getRequestMatcher(): RequestMatcherInterface
3436
{
3537
return new ChainRequestMatcher([
3638
new MethodRequestMatcher('POST'),
37-
new SchemeRequestMatcher('https'),
3839
new IsJsonRequestMatcher(),
40+
new HeaderRequestMatcher([
41+
'svix-id',
42+
'svix-timestamp',
43+
'svix-signature',
44+
]),
3945
]);
4046
}
4147

4248
protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?AbstractMailerEvent
4349
{
50+
if (!$secret) {
51+
throw new InvalidArgumentException('A non-empty secret is required.');
52+
}
53+
4454
$content = $request->toArray();
55+
4556
if (
4657
!isset($content['type'])
4758
|| !isset($content['created_at'])
@@ -55,10 +66,65 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr
5566
throw new RejectWebhookException(406, 'Payload is malformed.');
5667
}
5768

69+
$this->validateSignature($request->getContent(), $request->headers, $secret);
70+
5871
try {
5972
return $this->converter->convert($content);
6073
} catch (ParseException $e) {
6174
throw new RejectWebhookException(406, $e->getMessage(), $e);
6275
}
6376
}
77+
78+
private function validateSignature(string $payload, HeaderBag $headers, string $secret): void
79+
{
80+
$secret = $this->decodeSecret($secret);
81+
$messageId = $headers->get('svix-id');
82+
$messageTimestamp = (int) $headers->get('svix-timestamp');
83+
$messageSignature = $headers->get('svix-signature');
84+
85+
$signature = $this->sign($secret, $messageId, $messageTimestamp, $payload);
86+
$expectedSignature = explode(',', $signature, 2)[1];
87+
$passedSignatures = explode(' ', $messageSignature);
88+
$signatureFound = false;
89+
90+
foreach ($passedSignatures as $versionedSignature) {
91+
$signatureParts = explode(',', $versionedSignature, 2);
92+
$version = $signatureParts[0];
93+
94+
if ('v1' !== $version) {
95+
continue;
96+
}
97+
98+
$passedSignature = $signatureParts[1];
99+
100+
if (hash_equals($expectedSignature, $passedSignature)) {
101+
$signatureFound = true;
102+
103+
break;
104+
}
105+
}
106+
107+
if (!$signatureFound) {
108+
throw new RejectWebhookException(406, 'No signatures found matching the expected signature.');
109+
}
110+
}
111+
112+
private function sign(string $secret, string $messageId, int $timestamp, string $payload): string
113+
{
114+
$toSign = sprintf('%s.%s.%s', $messageId, $timestamp, $payload);
115+
$hash = hash_hmac('sha256', $toSign, $secret);
116+
$signature = base64_encode(pack('H*', $hash));
117+
118+
return 'v1,'.$signature;
119+
}
120+
121+
private function decodeSecret(string $secret): string
122+
{
123+
$prefix = 'whsec_';
124+
if (str_starts_with($secret, $prefix)) {
125+
$secret = substr($secret, \strlen($prefix));
126+
}
127+
128+
return base64_decode($secret);
129+
}
64130
}

0 commit comments

Comments
 (0)
0