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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
[Mailer] [Resend] Add Resend webhook signature verification
  • Loading branch information
welcoMattic authored and fabpot committed Apr 17, 2024
commit 8daa804989c8af32429ce0350846ff89b888e862
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": "[email protected]",
"headers": [
{
"name": "Sender",
"value": "[email protected]"
}
],
"subject": "Test subject",
"to": [
"[email protected]"
]
},
"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('[email protected]');
$wh->setTags([]);
$wh->setMetadata([
'created_at' => '2024-04-08T09:43:09.438Z',
'email_id' => '172c41ce-ba6d-4281-8a7a-541faa725748',
'from' => '[email protected]',
'headers' => [
[
'name' => 'Sender',
'value' => '[email protected]'
],
],
'subject' => 'Test subject',
'to' => [
'[email protected]',
],
]);
$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 <[email protected]>
*
* 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);
}
}