From 1d2bdf200a2ffedc2ab7569185cf760ee9b6399b Mon Sep 17 00:00:00 2001 From: Thibault Date: Mon, 9 Feb 2026 16:14:16 +0100 Subject: [PATCH] Add webhook signature verification --- backend/.env | 2 + backend/.env.test | 1 + .../Relay/RelayWebhookController.php | 16 +++++- backend/src/Service/AppConfig.php | 7 +++ .../Integration/Relay/RelayWebhookTest.php | 52 ++++++++++++++++++- 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/backend/.env b/backend/.env index ef6e80fc..a1796e98 100644 --- a/backend/.env +++ b/backend/.env @@ -28,6 +28,8 @@ URL_ARCHIVE= RELAY_URL=https://relay.hyvor.com # API key with scopes: `sends.send`, `domains.read`, `domains.write` RELAY_API_KEY= +# Secret used to sign the webhook payloads. +RELAY_WEBHOOK_SECRET= # Maximum number of emails that can be sent per second. # This is the rate limit of the Hyvor Relay project. diff --git a/backend/.env.test b/backend/.env.test index 8d09a198..6e2d6e94 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -27,6 +27,7 @@ FILESYSTEM_ADAPTER=local #RELAY_API_KEY=test-relay-key RELAY_URL=https://relay.hyvor.com RELAY_API_KEY=test-relay-key +RELAY_WEBHOOK_SECRET=test-relay-webhook-secret NOTIFICATION_MAIL_FROM_ADDRESS=from@example.com NOTIFICATION_MAIL_FROM_NAME='Hyvor Post' diff --git a/backend/src/Api/Public/Controller/Integration/Relay/RelayWebhookController.php b/backend/src/Api/Public/Controller/Integration/Relay/RelayWebhookController.php index 5f81ec03..586e8ebd 100644 --- a/backend/src/Api/Public/Controller/Integration/Relay/RelayWebhookController.php +++ b/backend/src/Api/Public/Controller/Integration/Relay/RelayWebhookController.php @@ -6,6 +6,7 @@ use App\Entity\Type\SendStatus; use App\Service\Domain\DomainService; use App\Service\Domain\Dto\UpdateDomainDto; +use App\Service\AppConfig; use App\Service\Issue\Dto\UpdateSendDto; use App\Service\Issue\SendService; use App\Service\Subscriber\SubscriberService; @@ -13,6 +14,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -37,6 +39,7 @@ class RelayWebhookController extends AbstractController { public function __construct( + private AppConfig $appConfig, private DomainService $domainService, private SubscriberService $subscriberService, private SendService $sendService, @@ -48,6 +51,17 @@ public function __construct( public function handleWebhook(Request $request): JsonResponse { $content = $request->getContent(); + + $signature = $request->headers->get('X-Signature'); + if ($signature === null) { + throw new UnauthorizedHttpException('', 'Missing webhook signature'); + } + + $expected = hash_hmac('sha256', $content, $this->appConfig->getRelayWebhookSecret()); + if (!hash_equals($expected, $signature)) { + throw new UnauthorizedHttpException('', 'Invalid webhook signature'); + } + /** @var array{ * 'event': string, * 'payload': array @@ -55,8 +69,6 @@ public function handleWebhook(Request $request): JsonResponse */ $data = json_decode($content, true); - // TODO: Validate the webhook - $event = $data['event']; $payload = $data['payload']; diff --git a/backend/src/Service/AppConfig.php b/backend/src/Service/AppConfig.php index 7199de39..0f25c798 100644 --- a/backend/src/Service/AppConfig.php +++ b/backend/src/Service/AppConfig.php @@ -20,6 +20,9 @@ public function __construct( #[Autowire('%env(string:RELAY_API_KEY)%')] private string $relayApiKey, + #[Autowire('%env(string:RELAY_WEBHOOK_SECRET)%')] + private string $relayWebhookSecret, + // Email configuration #[Autowire('%env(int:MAX_EMAILS_PER_SECOND)%')] private int $maxEmailsPerSecond, @@ -94,5 +97,9 @@ public function getNotificationRelayApiKey(): string return $this->notificationRelayApiKey ?: $this->relayApiKey; } + public function getRelayWebhookSecret(): string + { + return $this->relayWebhookSecret; + } } diff --git a/backend/tests/Api/Public/Integration/Relay/RelayWebhookTest.php b/backend/tests/Api/Public/Integration/Relay/RelayWebhookTest.php index fb738da0..a5ebf335 100644 --- a/backend/tests/Api/Public/Integration/Relay/RelayWebhookTest.php +++ b/backend/tests/Api/Public/Integration/Relay/RelayWebhookTest.php @@ -13,16 +13,24 @@ class RelayWebhookTest extends WebTestCase { + private const WEBHOOK_SECRET = 'test-relay-webhook-secret'; + /** * @param array $data + * @param array $headers */ - private function callWebhook(array $data): Response + private function callWebhook(array $data, array $headers = []): Response { + if (!isset($headers['X-Signature'])) { + $body = json_encode($data, JSON_THROW_ON_ERROR); + $headers['X-Signature'] = hash_hmac('sha256', $body, self::WEBHOOK_SECRET); + } return $this->publicApi( 'POST', '/integration/relay/webhook', - $data + $data, + $headers, ); } @@ -342,4 +350,44 @@ public function test_ignore_webhooks_for_emails_without_send_id(): void $response = $this->callWebhook($data); $this->assertSame(200, $response->getStatusCode()); } + + public function test_webhook_rejected_when_signature_missing(): void + { + $data = [ + "event" => "send.recipient.accepted", + "payload" => [ + "send" => [ + "headers" => [] + ], + "attempt" => [ + "created_at" => 1758221942 + ] + ] + ]; + + $response = $this->publicApi( + 'POST', + '/integration/relay/webhook', + $data, + ); + $this->assertSame(401, $response->getStatusCode()); + } + + public function test_webhook_rejected_when_signature_invalid(): void + { + $data = [ + "event" => "send.recipient.accepted", + "payload" => [ + "send" => [ + "headers" => [] + ], + "attempt" => [ + "created_at" => 1758221942 + ] + ] + ]; + + $response = $this->callWebhook($data, ['X-Signature' => 'invalid-signature']); + $this->assertSame(401, $response->getStatusCode()); + } } \ No newline at end of file