Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit eede9c5

Browse files
committed
Add Resend webhook signature verification
1 parent 9549cc2 commit eede9c5

File tree

6 files changed

+155
-4
lines changed

6 files changed

+155
-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": "[email protected]",
7+
"headers": [
8+
{
9+
"name": "Sender",
10+
"value": "[email protected]"
11+
}
12+
],
13+
"subject": "Test subject",
14+
"to": [
15+
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('[email protected]');
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' => '[email protected]',
12+
'headers' => [
13+
[
14+
'name' => 'Sender',
15+
'value' => '[email protected]'
16+
],
17+
],
18+
'subject' => 'Test subject',
19+
'to' => [
20+
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 <[email protected]>
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: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
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;
1617
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
1718
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
18-
use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher;
1919
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
2020
use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter;
21+
use Symfony\Component\Mailer\Exception\InvalidArgumentException;
2122
use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent;
2223
use Symfony\Component\RemoteEvent\Exception\ParseException;
2324
use Symfony\Component\Webhook\Client\AbstractRequestParser;
@@ -34,14 +35,18 @@ protected function getRequestMatcher(): RequestMatcherInterface
3435
{
3536
return new ChainRequestMatcher([
3637
new MethodRequestMatcher('POST'),
37-
new SchemeRequestMatcher('https'),
3838
new IsJsonRequestMatcher(),
3939
]);
4040
}
4141

4242
protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?AbstractMailerEvent
4343
{
44+
if (!$secret) {
45+
throw new InvalidArgumentException('A non-empty secret is required.');
46+
}
47+
4448
$content = $request->toArray();
49+
4550
if (
4651
!isset($content['type'])
4752
|| !isset($content['created_at'])
@@ -55,10 +60,70 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr
5560
throw new RejectWebhookException(406, 'Payload is malformed.');
5661
}
5762

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

0 commit comments

Comments
 (0)