diff --git a/src/Symfony/Component/Mailer/Bridge/Sweego/Webhook/SweegoRequestParser.php b/src/Symfony/Component/Mailer/Bridge/Sweego/Webhook/SweegoRequestParser.php index 775b755c3f26d..bcb9ec05170b8 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..c8ced3447b980 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: + service: notifier.webhook.request_parser.sweego + secret: '%env(SWEEGO_WEBHOOK_SECRET)%' +``` + +And a consumer: + +```php +#[AsRemoteEventConsumer(name: 'sweego')] +class SweegoConsumer implements ConsumerInterface +{ + public function consume(RemoteEvent|SmsEvent $event): void + { + // your code + } +} +``` + Resources --------- diff --git a/src/Symfony/Component/Notifier/Bridge/Sweego/SweegoTransport.php b/src/Symfony/Component/Notifier/Bridge/Sweego/SweegoTransport.php index 70bea13002e14..4bfa714a5ce79 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sweego/SweegoTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Sweego/SweegoTransport.php @@ -123,7 +123,7 @@ private function setCampaignType(array $body, array $options): array { $body['campaign-type'] = $this->campaignType; - if (!\array_key_exists(SweegoOptions::CAMPAIGN_TYPE, $options) && \is_string($options[SweegoOptions::CAMPAIGN_TYPE])) { + if (\array_key_exists(SweegoOptions::CAMPAIGN_TYPE, $options) && \is_string($options[SweegoOptions::CAMPAIGN_TYPE])) { $body['campaign-type'] = $options[SweegoOptions::CAMPAIGN_TYPE]; } @@ -134,7 +134,7 @@ private function setCampaignId(array $body, array $options): array { $body['campaign-id'] = $this->campaignId; - if (!\array_key_exists(SweegoOptions::CAMPAIGN_ID, $options) && \is_string($options[SweegoOptions::CAMPAIGN_ID])) { + if (\array_key_exists(SweegoOptions::CAMPAIGN_ID, $options) && \is_string($options[SweegoOptions::CAMPAIGN_ID])) { $body['campaign-id'] = $options[SweegoOptions::CAMPAIGN_ID]; } diff --git a/src/Symfony/Component/Notifier/Bridge/Sweego/Webhook/SweegoRequestParser.php b/src/Symfony/Component/Notifier/Bridge/Sweego/Webhook/SweegoRequestParser.php index e35620e956d28..4c543f1be66af 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,21 @@ 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.'); + } + } } +