diff --git a/src/Symfony/Component/HttpFoundation/ChainRequestMatcher.php b/src/Symfony/Component/HttpFoundation/ChainRequestMatcher.php index 29486fc8dfb06..5f15270575334 100644 --- a/src/Symfony/Component/HttpFoundation/ChainRequestMatcher.php +++ b/src/Symfony/Component/HttpFoundation/ChainRequestMatcher.php @@ -29,6 +29,7 @@ public function matches(Request $request): bool { foreach ($this->matchers as $matcher) { if (!$matcher->matches($request)) { + dump($request); return false; } } diff --git a/src/Symfony/Component/Mailer/Bridge/Sweego/README.md b/src/Symfony/Component/Mailer/Bridge/Sweego/README.md index 06205497dd959..557aa92b8bcec 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sweego/README.md +++ b/src/Symfony/Component/Mailer/Bridge/Sweego/README.md @@ -24,6 +24,33 @@ MAILER_DSN=sweego+api://API_KEY@default where: - `API_KEY` is your Sweego API Key +Webhook +------- + +Configure the webhook routing: + +```yaml +framework: + webhook: + routing: + sweego_mailer: + service: mailer.webhook.request_parser.sweego + secret: '%env(SWEEGO_WEBHOOK_SECRET)%' +``` + +And a consumer: + +```php +#[AsRemoteEventConsumer(name: 'sweego_mailer')] +class SweegoMailEventConsumer implements ConsumerInterface +{ + public function consume(RemoteEvent|AbstractMailerEvent $event): void + { + // your code + } +} +``` + Resources --------- diff --git a/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/Fixtures/sent.json b/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/Fixtures/sent.json deleted file mode 100644 index de6504c1d867c..0000000000000 --- a/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/Fixtures/sent.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "event_type": "email_sent", - "timestamp": "2024-08-15T16:05:59+00:00", - "swg_uid": "02-0d4affd0-1183-43b1-a980-ab30b3374dd3", - "event_id": "97cf3afe-f63a-4d92-abac-bde9c7e6523e", - "channel": "email", - "headers": { - "x-transaction-id": "d4fbec9d-eed9-44d5-af47-c1126467a5ca" - }, - "campaign_tags": null, - "campaign_type": "transac", - "campaign_id": "transac", - "recipient": "recipient@example.com", - "domain_from": "example.org" -} diff --git a/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/Fixtures/sent.php b/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/Fixtures/sent.php deleted file mode 100644 index b771b2e791954..0000000000000 --- a/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/Fixtures/sent.php +++ /dev/null @@ -1,12 +0,0 @@ -setRecipientEmail('recipient@example.com'); -$wh->setMetadata([ - 'x-transaction-id' => 'd4fbec9d-eed9-44d5-af47-c1126467a5ca', -]); -$wh->setDate(\DateTimeImmutable::createFromFormat(\DATE_ATOM, '2024-08-15T16:05:59+00:00')); - -return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php index e60f2ebb3f882..329354c29ab06 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php @@ -28,6 +28,9 @@ protected function createRequest(string $payload): Request { return Request::create('/', 'POST', [], [], [], [ 'Content-Type' => 'application/json', + 'HTTP_webhook-id' => '9f26b9d0-13d7-410c-ba04-5019cd30e6d0', + 'HTTP_webhook-timestamp' => '1723737959', + 'HTTP_webhook-signature' => 'W+fm4VPshCGjuT0HxyV00QEbFitZd2Rdvx82bWM7VXc=', ], $payload); } } diff --git a/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/SweegoWrongSignatureRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/SweegoWrongSignatureRequestParserTest.php new file mode 100644 index 0000000000000..e797a3b542f31 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/SweegoWrongSignatureRequestParserTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Sweego\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\Bridge\Sweego\RemoteEvent\SweegoPayloadConverter; +use Symfony\Component\Mailer\Bridge\Sweego\Webhook\SweegoRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Exception\RejectWebhookException; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +class SweegoWrongSignatureRequestParserTest extends AbstractRequestParserTestCase +{ + protected function createRequestParser(): RequestParserInterface + { + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Invalid signature.'); + + return new SweegoRequestParser(new SweegoPayloadConverter()); + } + + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + 'HTTP_webhook-id' => '9f26b9d0-13d7-410c-ba04-5019cd30e6d0', + 'HTTP_webhook-timestamp' => '1723737959', + 'HTTP_webhook-signature' => 'wrong_signature', + ], $payload); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Sweego/Webhook/SweegoRequestParser.php b/src/Symfony/Component/Mailer/Bridge/Sweego/Webhook/SweegoRequestParser.php index 775b755c3f26d..ec81bbdec9b68 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sweego/Webhook/SweegoRequestParser.php +++ b/src/Symfony/Component/Mailer/Bridge/Sweego/Webhook/SweegoRequestParser.php @@ -13,6 +13,7 @@ use Symfony\Component\HttpFoundation\ChainRequestMatcher; 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\RequestMatcherInterface; @@ -34,6 +35,7 @@ protected function getRequestMatcher(): RequestMatcherInterface return new ChainRequestMatcher([ new MethodRequestMatcher('POST'), new IsJsonRequestMatcher(), + new HeaderRequestMatcher(['webhook-id', 'webhook-timestamp', 'webhook-signature']), ]); } @@ -51,10 +53,28 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr throw new RejectWebhookException(406, 'Payload is malformed.'); } + $this->validateSignature($request, $secret); + try { return $this->converter->convert($content); } catch (ParseException $e) { throw new RejectWebhookException(406, $e->getMessage(), $e); } } + + private function validateSignature(Request $request, string $secret): void + { + $contentToSign = \sprintf( + '%s.%s.%s', + $request->headers->get('webhook-id'), + $request->headers->get('webhook-timestamp'), + $request->getContent(), + ); + + $computedSignature = base64_encode(hash_hmac('sha256', $contentToSign, base64_decode($secret), true)); + + if (!hash_equals($computedSignature, $request->headers->get('webhook-signature'))) { + throw new RejectWebhookException(403, 'Invalid signature.'); + } + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Sweego/README.md b/src/Symfony/Component/Notifier/Bridge/Sweego/README.md index 85fb83342d40b..5366af621993d 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sweego/README.md +++ b/src/Symfony/Component/Notifier/Bridge/Sweego/README.md @@ -44,6 +44,33 @@ $sms->options($options); $texter->send($sms); ``` +Webhook +------- + +Configure the webhook routing: + +```yaml +framework: + webhook: + routing: + sweego_sms: + service: notifier.webhook.request_parser.sweego + secret: '%env(SWEEGO_WEBHOOK_SECRET)%' +``` + +And a consumer: + +```php +#[AsRemoteEventConsumer(name: 'sweego_sms')] +class SweegoSmsEventConsumer implements ConsumerInterface +{ + public function consume(RemoteEvent|SmsEvent $event): void + { + // your code + } +} +``` + Resources --------- diff --git a/src/Symfony/Component/Notifier/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php b/src/Symfony/Component/Notifier/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php index 50d74d158246c..8357a7748433d 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Notifier\Bridge\Sweego\Tests\Webhook; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Notifier\Bridge\Sweego\Webhook\SweegoRequestParser; use Symfony\Component\Webhook\Client\RequestParserInterface; use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; @@ -21,4 +22,14 @@ protected function createRequestParser(): RequestParserInterface { return new SweegoRequestParser(); } + + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + 'HTTP_webhook-id' => 'a5ccc627-6e43-4012-bb29-f1bfe3a3d13e', + 'HTTP_webhook-timestamp' => '1725290740', + 'HTTP_webhook-signature' => 'k7SwzHXZqVKNvCpp6HwGS/5aDZ6NraYnKmVkBdx7MHE=', + ], $payload); + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Sweego/Tests/Webhook/SweegoWrongSignatureRequestParserTest.php b/src/Symfony/Component/Notifier/Bridge/Sweego/Tests/Webhook/SweegoWrongSignatureRequestParserTest.php new file mode 100644 index 0000000000000..69689d4195553 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Sweego/Tests/Webhook/SweegoWrongSignatureRequestParserTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Sweego\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Notifier\Bridge\Sweego\Webhook\SweegoRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Exception\RejectWebhookException; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +class SweegoWrongSignatureRequestParserTest extends AbstractRequestParserTestCase +{ + protected function createRequestParser(): RequestParserInterface + { + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Invalid signature.'); + + return new SweegoRequestParser(); + } + + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + 'HTTP_webhook-id' => 'a5ccc627-6e43-4012-bb29-f1bfe3a3d13e', + 'HTTP_webhook-timestamp' => '1725290740', + 'HTTP_webhook-signature' => 'wrong_signature', + ], $payload); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Sweego/Webhook/SweegoRequestParser.php b/src/Symfony/Component/Notifier/Bridge/Sweego/Webhook/SweegoRequestParser.php index e35620e956d28..68256d002d00e 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sweego/Webhook/SweegoRequestParser.php +++ b/src/Symfony/Component/Notifier/Bridge/Sweego/Webhook/SweegoRequestParser.php @@ -13,6 +13,7 @@ use Symfony\Component\HttpFoundation\ChainRequestMatcher; 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\RequestMatcherInterface; @@ -32,6 +33,7 @@ protected function getRequestMatcher(): RequestMatcherInterface return new ChainRequestMatcher([ new MethodRequestMatcher('POST'), new IsJsonRequestMatcher(), + new HeaderRequestMatcher(['webhook-id', 'webhook-timestamp', 'webhook-signature']), ]); } @@ -43,6 +45,8 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr throw new RejectWebhookException(406, 'Payload is malformed.'); } + $this->validateSignature($request, $secret); + $name = match ($payload['event_type']) { 'sms_sent' => SmsEvent::DELIVERED, default => throw new RejectWebhookException(406, \sprintf('Unsupported event "%s".', $payload['event'])), @@ -53,4 +57,20 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr return $event; } + + private function validateSignature(Request $request, string $secret): void + { + $contentToSign = \sprintf( + '%s.%s.%s', + $request->headers->get('webhook-id'), + $request->headers->get('webhook-timestamp'), + $request->getContent(), + ); + + $computedSignature = base64_encode(hash_hmac('sha256', $contentToSign, base64_decode($secret), true)); + + if (!hash_equals($computedSignature, $request->headers->get('webhook-signature'))) { + throw new RejectWebhookException(403, 'Invalid signature.'); + } + } }