8000 feature #58651 [Mailer][Notifier] Add webhooks signature verification… · symfony/symfony@3a804f5 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3a804f5

Browse files
committed
feature #58651 [Mailer][Notifier] Add webhooks signature verification on Sweego bridges (welcoMattic)
This PR was merged into the 7.3 branch. Discussion ---------- [Mailer][Notifier] Add webhooks signature verification on Sweego bridges | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Issues | Fix #... <!-- prefix each issue number with "Fix #", no need to create an issue if none exists, explain below instead --> | License | MIT This PR follows the 2 previous ones (#57431, and #58322). It brings webhook signature verification to improve security. Sweego documentation about webhook is here https://learn.sweego.io/docs/webhooks/webhook_signature Commits ------- 9c59199 Add webhooks signature verification on Sweego bridges
2 parents 2d1838a + 9c59199 commit 3a804f5

File tree

11 files changed

+190
-27
lines changed

11 files changed

+190
-27
lines changed

src/Symfony/Component/Mailer/Bridge/Sweego/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,33 @@ MAILER_DSN=sweego+api://API_KEY@default
2424
where:
2525
- `API_KEY` is your Sweego API Key
2626

27+
Webhook
28+
-------
29+
30+
Configure the webhook routing:
31+
32+
```yaml
33+
framework:
34+
webhook:
35+
routing:
36+
sweego_mailer:
37+
service: mailer.webhook.request_parser.sweego
38+
secret: '%env(SWEEGO_WEBHOOK_SECRET)%'
39+
```
40+
41+
And a consumer:
42+
43+
```php
44+
#[AsRemoteEventConsumer(name: 'sweego_mailer')]
45+
class SweegoMailEventConsumer implements ConsumerInterface
46+
{
47+
public function consume(RemoteEvent|AbstractMailerEvent $event): void
48+
{
49+
// your code
50+
}
51+
}
52+
```
53+
2754
Sponsor
2855
-------
2956

src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/Fixtures/sent.json

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/Fixtures/sent.php

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/Symfony/Component/Mailer/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ protected function createRequest(string $payload): Request
2828
{
2929
return Request::create('/', 'POST', [], [], [], [
3030
'Content-Type' => 'application/json',
31+
'HTTP_webhook-id' => '9f26b9d0-13d7-410c-ba04-5019cd30e6d0',
32+
'HTTP_webhook-timestamp' => '1723737959',
33+
'HTTP_webhook-signature' => 'W+fm4VPshCGjuT0HxyV00QEbFitZd2Rdvx82bWM7VXc=',
3134
], $payload);
3235
}
3336
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Mailer\Bridge\Sweego\Tests\Webhook;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\Mailer\Bridge\Sweego\RemoteEvent\SweegoPayloadConverter;
16+
use Symfony\Component\Mailer\Bridge\Sweego\Webhook\SweegoRequestParser;
17+
use Symfony\Component\Webhook\Client\RequestParserInterface;
18+
use Symfony\Component\Webhook\Exception\RejectWebhookException;
19+
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;
20+
21+
class SweegoWrongSignatureRequestParserTest extends AbstractRequestParserTestCase
22+
{
23+
protected function createRequestParser(): RequestParserInterface
24+
{
25+
$this->expectException(RejectWebhookException::class);
26+
$this->expectExceptionMessage('Invalid signature.');
27+
28+
return new SweegoRequestParser(new SweegoPayloadConverter());
29+
}
30+
31+
protected function createRequest(string $payload): Request
32+
{
33+
return Request::create('/', 'POST', [], [], [], [
34+
'Content-Type' => 'application/json',
35+
'HTTP_webhook-id' => '9f26b9d0-13d7-410c-ba04-5019cd30e6d0',
36+
'HTTP_webhook-timestamp' => '1723737959',
37+
'HTTP_webhook-signature' => 'wrong_signature',
38+
], $payload);
39+
}
40+
}

src/Symfony/Component/Mailer/Bridge/Sweego/Webhook/SweegoRequestParser.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
1515
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\RequestMatcher\HeaderRequestMatcher;
1617
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
1718
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
1819
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
@@ -34,6 +35,7 @@ protected function getRequestMatcher(): RequestMatcherInterface
3435
return new ChainRequestMatcher([
3536
new MethodRequestMatcher('POST'),
3637
new IsJsonRequestMatcher(),
38+
new HeaderRequestMatcher(['webhook-id', 'webhook-timestamp', 'webhook-signature']),
3739
]);
3840
}
3941

@@ -51,10 +53,28 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr
5153
throw new RejectWebhookException(406, 'Payload is malformed.');
5254
}
5355

56+
$this->validateSignature($request, $secret);
57+
5458
try {
5559
return $this->converter->convert($content);
5660
} catch (ParseException $e) {
5761
throw new RejectWebhookException(406, $e->getMessage(), $e);
5862
}
5963
}
64+
65+
private function validateSignature(Request $request, string $secret): void
66+
{
67+
$contentToSign = \sprintf(
68+
'%s.%s.%s',
69+
$request->headers->get('webhook-id'),
70+
$request->headers->get('webhook-timestamp'),
71+
$request->getContent(),
72+
);
73+
74+
$computedSignature = base64_encode(hash_hmac('sha256', $contentToSign, base64_decode($secret), true));
75+
76+
if (!hash_equals($computedSignature, $request->headers->get('webhook-signature'))) {
77+
throw new RejectWebhookException(403, 'Invalid signature.');
78+
}
79+
}
6080
}

src/Symfony/Component/Notifier/Bridge/Sweego/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,33 @@ $sms->options($options);
4444
$texter->send($sms);
4545
```
4646

47+
Webhook
48+
-------
49+
50+
Configure the webhook routing:
51+
52+
```yaml
53+
framework:
54+
webhook:
55+
routing:
56+
sweego_sms:
57+
service: notifier.webhook.request_parser.sweego
58+
secret: '%env(SWEEGO_WEBHOOK_SECRET)%'
59+
```
60+
61+
And a consumer:
62+
63+
```php
64+
#[AsRemoteEventConsumer(name: 'sweego_sms')]
65+
class SweegoSmsEventConsumer implements ConsumerInterface
66+
{
67+
public function consume(RemoteEvent|SmsEvent $event): void
68+
{
69+
// your code
70+
}
71+
}
72+
```
73+
4774Sponsor
4875
-------
4976

src/Symfony/Component/Notifier/Bridge/Sweego/Tests/Webhook/SweegoRequestParserTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Notifier\Bridge\Sweego\Tests\Webhook;
1313

14+
use Symfony\Component\HttpFoundation\Request;
1415
use Symfony\Component\Notifier\Bridge\Sweego\Webhook\SweegoRequestParser;
1516
use Symfony\Component\Webhook\Client\RequestParserInterface;
1617
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;
@@ -21,4 +22,14 @@ protected function createRequestParser(): RequestParserInterface
2122
{
2223
return new SweegoRequestParser();
2324
}
25+
26+
protected function createRequest(string $payload): Request
27+
{
28+
return Request::create('/', 'POST', [], [], [], [
29+
'Content-Type' => 'application/json',
30+
'HTTP_webhook-id' => 'a5ccc627-6e43-4012-bb29-f1bfe3a3d13e',
31+
'HTTP_webhook-timestamp' => '1725290740',
32+
'HTTP_webhook-signature' => 'k7SwzHXZqVKNvCpp6HwGS/5aDZ6NraYnKmVkBdx7MHE=',
33+
], $payload);
34+
}
2435
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Notifier\Bridge\Sweego\Tests\Webhook;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\Notifier\Bridge\Sweego\Webhook\SweegoRequestParser;
16+
use Symfony\Component\Webhook\Client\RequestParserInterface;
17+
use Symfony\Component\Webhook\Exception\RejectWebhookException;
18+
use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase;
19+
20+
class SweegoWrongSignatureRequestParserTest extends AbstractRequestParserTestCase
21+
{
22+
protected function createRequestParser(): RequestParserInterface
23+
{
24+
$this->expectException(RejectWebhookException::class);
25+
$this->expectExceptionMessage('Invalid signature.');
26+
27+
return new SweegoRequestParser();
28+
}
29+
30+
protected function createRequest(string $payload): Request
31+
{
32+
return Request::create('/', 'POST', [], [], [], [
33+
'Content-Type' => 'application/json',
34+
'HTTP_webhook-id' => 'a5ccc627-6e43-4012-bb29-f1bfe3a3d13e',
35+
'HTTP_webhook-timestamp' => '1725290740',
36+
'HTTP_webhook-signature' => 'wrong_signature',
37+
], $payload);
38+
}
39+
}

src/Symfony/Component/Notifier/Bridge/Sweego/Webhook/SweegoRequestParser.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
1515
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\RequestMatcher\HeaderRequestMatcher;
1617
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
1718
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
1819
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
@@ -32,6 +33,7 @@ protected function getRequestMatcher(): RequestMatcherInterface
3233
return new ChainRequestMatcher([
3334
new MethodRequestMatcher('POST'),
3435
new IsJsonRequestMatcher(),
36+
new HeaderRequestMatcher(['webhook-id', 'webhook-timestamp', 'webhook-signature']),
3537
]);
3638
}
3739

@@ -43,6 +45,8 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr
4345
throw new RejectWebhookException(406, 'Payload is malformed.');
4446
}
4547

48+
$this->validateSignature($request, $secret);
49+
4650
$name = match ($payload['event_type']) {
4751
'sms_sent' => SmsEvent::DELIVERED,
4852
default => throw new RejectWebhookException(406, \sprintf('Unsupported event "%s".', $payload['event'])),
@@ -53,4 +57,20 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr
5357

5458
return $event;
5559
}
60+
61+
private function validateSignature(Request $request, string $secret): void
62+
{
63+
$contentToSign = \sprintf(
64+
'%s.%s.%s',
65+
$request->headers->get('webhook-id'),
66+
$request->headers->get('webhook-timestamp'),
67+
$request->getCont D24A ent(),
68+
);
69+
70+
$computedSignature = base64_encode(hash_hmac('sha256', $contentToSign, base64_decode($secret), true));
71+
72+
if (!hash_equals($computedSignature, $request->headers->get('webhook-signature'))) {
73+
throw new RejectWebhookException(403, 'Invalid signature.');
74+
}
75+
}
5676
}

src/Symfony/Component/Notifier/Bridge/Sweego/composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"require-dev": {
2424
"symfony/webhook": "^6.4|^7.0"
2525
},
26+
"conflict": {
27+
"symfony/http-foundation": "<7.1"
28+
},
2629
"autoload": {
2730
"psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Sweego\\": "" },
2831
"exclude-from-classmap": [

0 commit comments

Comments
 (0)
0