diff --git a/README.md b/README.md index dc53229..4b2d7a4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Of course, Receiver can receive webhooks from any source using [custom providers ## Table of Contents - [Installation](#installation) +- [Configuration](#configuration) - [Receiving Webhooks](#receiving-webhooks) - [The Basics](#the-basics) - [Receiving from multiple apps](#receiving-from-multiple-apps) @@ -53,6 +54,42 @@ Optional: **Stripe** support requires [`stripe/stripe-php`](https://github.com/stripe/stripe-php) +## Configuration + +Currently, configuration is only supported for the Postmark provider. To configure the Postmark integration, modify the `config/services.php` file. + +Add a new key called `webhook` for `postmark` configuration, and it should look like this after the modification. + +``` +'postmark' => [ + 'token' => env('POSTMARK_TOKEN'), + 'webhook' => [ + 'verification_types' => [ + 'auth', + 'headers', + 'ips', + ], + + 'headers' => [ + 'User-Agent' => 'Postmark', + ], + + 'ips' => [ + '3.134.147.250', + '50.31.156.6', + '50.31.156.77', + '18.217.206.57', + ], + ], +], +``` + +For Postmark provider, there are three different verification types are available. These checks will be run sequentially based on how they are defined in the configuration. + +1. `auth` - Perform verification via the HTTP authentication. +2. `headers` - Perform verification based on the predefined key-value pair of expected headers in the config. Additional headers for the webhook can be configured directly in the Postmark dashboard. +3. `ips` - Perform verification based on the IP addresses in the allow-list. By default, [these IP addresses](https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks) are added to the allow-list. + ## Receiving Webhooks ### The Basics diff --git a/src/Providers/PostmarkProvider.php b/src/Providers/PostmarkProvider.php index 825e32f..e0e88bf 100644 --- a/src/Providers/PostmarkProvider.php +++ b/src/Providers/PostmarkProvider.php @@ -15,13 +15,37 @@ class PostmarkProvider extends AbstractProvider */ public function verify(Request $request): bool { - try { - Auth::onceBasic(); + foreach (config('services.postmark.webhook.verification_types') ?? [] as $verification_type) { + switch ($verification_type) { + case 'auth': + try { + Auth::onceBasic(); + } catch (\Exception $exception) { + return false; + } + break; - return true; - } catch (\Exception $exception) { - return false; + case 'headers': + foreach (config('services.postmark.webhook.headers') ?? [] as $key => $value) { + if (!$request->hasHeader($key)) { + return false; + } + + if ($request->header($key) !== $value) { + return false; + } + } + break; + + case 'ips': + if (!in_array($request->getClientIp(), config('services.postmark.webhook.ips') ?? [], true)) { + return false; + } + break; + } } + + return true; } /** diff --git a/tests/PostmarkProviderTest.php b/tests/PostmarkProviderTest.php new file mode 100644 index 0000000..b66cf3e --- /dev/null +++ b/tests/PostmarkProviderTest.php @@ -0,0 +1,158 @@ +setupBaseRequest($request); + + $provider = new PostmarkProvider; + $provider->receive($request); + + $webhook = $provider->webhook(); + + $this->assertInstanceOf(Webhook::class, $webhook); + } + + public function test_it_will_verify_postmark_webhook_with_valid_headers() + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $request = Mockery::mock(Request::class); + + $this->setupBaseRequest($request); + + $request->allows('hasHeader')->with('foo')->andReturns(true); + $request->allows('header')->with('foo')->andReturns('bar'); + + $provider = new PostmarkProvider; + $provider->receive($request); + + $webhook = $provider->webhook(); + + $this->assertInstanceOf(Webhook::class, $webhook); + } + + public function test_it_will_deny_postmark_webhook_with_missing_headers() + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = Mockery::mock(Request::class); + + $this->setupBaseRequest($request); + + $request->allows('hasHeader')->with('foo')->andReturns(false); + + $provider = new PostmarkProvider; + $provider->receive($request); + } + + public function test_it_will_deny_postmark_webhook_with_invalid_headers() + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = Mockery::mock(Request::class); + + $this->setupBaseRequest($request); + + $request->allows('hasHeader')->with('foo')->andReturns(true); + $request->allows('header')->with('foo')->andReturns('baz'); + + $request->allows('hasHeader')->with('foo')->andReturns(false); + + $provider = new PostmarkProvider; + $provider->receive($request); + } + + public function test_it_will_verify_postmark_webhook_with_valid_ip() + { + Config::set('services.postmark.webhook.verification_types', ['ips']); + + $request = Mockery::mock(Request::class); + + $this->setupBaseRequest($request); + + $request->allows('getClientIp')->andReturns('123.123.123.123'); + + $provider = new PostmarkProvider; + $provider->receive($request); + + $webhook = $provider->webhook(); + + $this->assertInstanceOf(Webhook::class, $webhook); + } + + public function test_it_will_deny_postmark_webhook_with_invalid_ip() + { + Config::set('services.postmark.webhook.verification_types', ['ips']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = Mockery::mock(Request::class); + + $this->setupBaseRequest($request); + + $request->allows('getClientIp')->andReturns('111.111.111.111'); + + $provider = new PostmarkProvider; + $provider->receive($request); + } + + protected function setupBaseRequest(Request $request): Request + { + $request->allows('filled')->with('RecordType')->andReturns($this->mockPayload('RecordType')); + $request->allows('input')->with('RecordType')->andReturns($this->mockPayload('RecordType')); + $request->allows('all')->andReturns($this->mockPayload()); + $request->allows('header')->with('User-Agent')->andReturns('Postmark'); + $request->allows('hasHeader')->with('User-Agent')->andReturns(true); + $request->allows('getContent')->andReturns(json_encode($this->mockPayload())); + + return $request; + } + + /** + * https://postmarkapp.com/developer/webhooks/delivery-webhook#testing-with-curl + * + * @param string|null $key + * @return mixed + */ + protected function mockPayload(string $key = null): mixed + { + $data = [ + 'MessageID' => '883953f4-6105-42a2-a16a-77a8eac79483', + 'Recipient' => 'john@example.com', + 'DeliveredAt' => '2014-08-01T13:28:10.2735393-04:00', + 'Details' => 'Test delivery webhook details', + 'Tag' => 'welcome-email', + 'ServerID' => 23, + 'Metadata' => [ + 'a_key' => 'a_value', + 'b_key' => 'b_value' + ], + 'RecordType' => 'Delivery', + 'MessageStream' => 'outbound', + ]; + + return $key ? data_get($data, $key) : $data; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 67036d2..dc6054a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -26,6 +26,16 @@ protected function getEnvironmentSetUp($app) 'redirect' => 'http://your-callback-url', 'webhook_secret' => 'slack-webhook-secret', ]); + + $app['config']->set('services.postmark.webhook', [ + 'headers' => [ + 'User-Agent' => 'Postmark', + 'foo' => 'bar', + ], + 'ips' => [ + '123.123.123.123', + ], + ]); } /**