diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendApiTransportTest.php similarity index 99% rename from src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendApiTransportTest.php rename to src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendApiTransportTest.php index 1cdadd3df95c9..46a93365ac747 100644 --- a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendApiTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendApiTransportTest.php @@ -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; diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendTransportFactoryTest.php similarity index 97% rename from src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendTransportFactoryTest.php rename to src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendTransportFactoryTest.php index 56c7ea3921c7b..d87cdc3a76e7c 100644 --- a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendTransportFactoryTest.php @@ -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; diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/Fixtures/sent.json b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/Fixtures/sent.json new file mode 100644 index 0000000000000..676d129a03db9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/Fixtures/sent.json @@ -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" +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/Fixtures/sent.php b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/Fixtures/sent.php new file mode 100644 index 0000000000000..9872c099a7c6c --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/Fixtures/sent.php @@ -0,0 +1,26 @@ +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; diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/ResendRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/ResendRequestParserTest.php new file mode 100644 index 0000000000000..bd6095fbb34d9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Webhook/ResendRequestParserTest.php @@ -0,0 +1,41 @@ + + * + * 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)); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Webhook/ResendRequestParser.php b/src/Symfony/Component/Mailer/Bridge/Resend/Webhook/ResendRequestParser.php index b5ed40f1d85cd..ea79cfc8c8da2 100644 --- a/src/Symfony/Component/Mailer/Bridge/Resend/Webhook/ResendRequestParser.php +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Webhook/ResendRequestParser.php @@ -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\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; @@ -34,14 +36,18 @@ protected function getRequestMatcher(): RequestMatcherInterface { return new ChainRequestMatcher([ new MethodRequestMatcher('POST'), - new SchemeRequestMatcher('https'), new IsJsonRequestMatcher(), ]); } 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']) @@ -55,10 +61,70 @@ 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); + + if ($headers->has('svix-id') && $headers->has('svix-timestamp') && $headers->has('svix-signature')) { + $messageId = $headers->get('svix-id'); + $messageTimestamp = (int) $headers->get('svix-timestamp'); + $messageSignature = $headers->get('svix-signature'); + } else { + throw new RejectWebhookException(406, 'Missing required headers.'); + } + + $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 ($version !== 'v1') { + 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); + } }