Skip to content

Commit

Permalink
Add webhooks signature verification on Sweego bridges
Browse files Browse the repository at this point in the history
  • Loading branch information
welcoMattic committed Dec 4, 2024
1 parent cc1802d commit 56fa444
Show file tree
Hide file tree
Showing 10 changed files with 187 additions and 27 deletions.
27 changes: 27 additions & 0 deletions src/Symfony/Component/Mailer/Bridge/Sweego/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
```

Sponsor
-------

Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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\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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +35,7 @@ protected function getRequestMatcher(): RequestMatcherInterface
return new ChainRequestMatcher([
new MethodRequestMatcher('POST'),
new IsJsonRequestMatcher(),
new HeaderRequestMatcher(['webhook-id', 'webhook-timestamp', 'webhook-signature']),
]);
}

Expand All @@ -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.');
}
}
}
27 changes: 27 additions & 0 deletions src/Symfony/Component/Notifier/Bridge/Sweego/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
```

Sponsor
-------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?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\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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +33,7 @@ protected function getRequestMatcher(): RequestMatcherInterface
return new ChainRequestMatcher([
new MethodRequestMatcher('POST'),
new IsJsonRequestMatcher(),
new HeaderRequestMatcher(['webhook-id', 'webhook-timestamp', 'webhook-signature']),
]);
}

Expand All @@ -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'])),
Expand All @@ -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.');
}
}
}

0 comments on commit 56fa444

Please sign in to comment.