diff --git a/composer.json b/composer.json index ced55f1..08992ab 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ } ], "require": { - "illuminate/support": "^6.0|^7.0" + "illuminate/support": "^6.0|^7.0", + "aws/aws-php-sns-message-validator": "^1.6" }, "autoload": { "psr-4": { diff --git a/src/Concerns/HandlesSns.php b/src/Concerns/HandlesSns.php index c46abee..f365e6a 100644 --- a/src/Concerns/HandlesSns.php +++ b/src/Concerns/HandlesSns.php @@ -2,29 +2,55 @@ namespace Rennokki\LaravelSnsEvents\Concerns; +use Aws\Sns\Message; +use Aws\Sns\MessageValidator; +use Exception; use Illuminate\Http\Request; trait HandlesSns { /** - * Get the payload content from the request. + * Get the SNS message as array. * * @param \Illuminate\Http\Request $request - * @return null|string + * @return \Aws\Sns\Message */ - public function getRequestContent(Request $request) + public function getSnsMessage(Request $request) { - return $request->getContent() ?: file_get_contents('php://input'); + try { + return Message::fromJsonString( + $request->getContent() ?: file_get_contents('php://input') + ); + } catch (Exception $e) { + return new Message([]); + } } /** - * Get the JSON-decoded content. + * Check if the SNS message is valid. * * @param \Illuminate\Http\Request $request - * @return array + * @return bool */ - public function getSnsMessage(Request $request): array + public function snsMessageIsValid(Request $request): bool { - return json_decode($this->getRequestContent($request), true); + try { + return $this->getMessageValidator($request)->isValid( + $this->getSnsMessage($request) + ); + } catch (Exception $e) { + return false; + } + } + + /** + * Get the message validator instance. + * + * @param \Illuminate\Http\Request $request + * @return \Aws\Sns\MessageValidator + */ + protected function getMessageValidator(Request $request) + { + return new MessageValidator; } } diff --git a/src/Http/Controllers/SnsController.php b/src/Http/Controllers/SnsController.php index 78baa97..65a8d3c 100644 --- a/src/Http/Controllers/SnsController.php +++ b/src/Http/Controllers/SnsController.php @@ -20,7 +20,11 @@ class SnsController extends Controller */ public function handle(Request $request) { - $snsMessage = $this->getSnsMessage($request); + if (! $this->snsMessageIsValid($request)) { + return $this->okStatus(); + } + + $snsMessage = $this->getSnsMessage($request)->toArray(); if (isset($snsMessage['Type'])) { if ($snsMessage['Type'] === 'SubscriptionConfirmation') { @@ -46,7 +50,7 @@ public function handle(Request $request) } } - return response('OK', 200); + return $this->okStatus(); } /** @@ -121,4 +125,14 @@ protected function onSubscriptionConfirmation(array $snsMessage, Request $reques { // } + + /** + * Get a 200 OK status. + * + * @return \Illuminate\Http\Response + */ + protected function okStatus() + { + return response('OK', 200); + } } diff --git a/tests/Controllers/CustomSnsController.php b/tests/Controllers/CustomSnsController.php index bbb6ea4..bb306d9 100644 --- a/tests/Controllers/CustomSnsController.php +++ b/tests/Controllers/CustomSnsController.php @@ -2,6 +2,7 @@ namespace Rennokki\LaravelSnsEvents\Tests\Controllers; +use Aws\Sns\MessageValidator; use Illuminate\Http\Request; use Rennokki\LaravelSnsEvents\Http\Controllers\SnsController; use Rennokki\LaravelSnsEvents\Tests\Events\CustomSnsEvent; @@ -86,4 +87,17 @@ protected function onSubscriptionConfirmation(array $snsMessage, Request $reques { mt_rand(0, 10000); } + + /** + * Get the message validator instance. + * + * @param \Illuminate\Http\Request $request + * @return \Aws\Sns\MessageValidator + */ + protected function getMessageValidator(Request $request) + { + return new MessageValidator(function ($url) use ($request) { + return $request->certificate ?: $url; + }); + } } diff --git a/tests/Controllers/SnsController.php b/tests/Controllers/SnsController.php new file mode 100644 index 0000000..5945f92 --- /dev/null +++ b/tests/Controllers/SnsController.php @@ -0,0 +1,23 @@ +certificate ?: $url; + }); + } +} diff --git a/tests/EventTest.php b/tests/EventTest.php index 21645a0..79f9f83 100644 --- a/tests/EventTest.php +++ b/tests/EventTest.php @@ -14,8 +14,22 @@ public function test_no_event_triggering_on_bad_request() { Event::fake(); - $this - ->json('GET', route('sns')) + $this->json('GET', route('sns')) + ->assertSee('OK'); + + Event::assertNotDispatched(SnsNotification::class); + Event::assertNotDispatched(SnsSubscriptionConfirmation::class); + + $this->json('GET', route('sns', ['certificate' => static::$certificate])) + ->assertSee('OK'); + + Event::assertNotDispatched(SnsNotification::class); + Event::assertNotDispatched(SnsSubscriptionConfirmation::class); + + $payload = $this->getSubscriptionConfirmationPayload(); + + $this->withHeaders($this->getHeadersForMessage($payload)) + ->json('GET', route('sns', ['certificate' => static::$certificate])) ->assertSee('OK'); Event::assertNotDispatched(SnsNotification::class); @@ -26,12 +40,13 @@ public function test_subscription_confirmation() { Event::fake(); - $this - ->withHeaders([ - 'x-test-header' => 1, - ]) - ->json('POST', route('sns'), $this->getSubscriptionConfirmationPayload()) - ->assertSee('OK'); + $payload = $this->getSubscriptionConfirmationPayload(); + + $this->withHeaders(array_merge($this->getHeadersForMessage($payload), [ + 'x-test-header' => 1, + ])) + ->json('POST', route('sns', ['certificate' => static::$certificate]), $payload) + ->assertSee('OK'); Event::assertNotDispatched(SnsNotification::class); @@ -48,17 +63,16 @@ public function test_notification_confirmation() { Event::fake(); - $payload = json_encode([ + $payload = $this->getNotificationPayload([ 'test' => 1, 'sns' => true, ]); - $this - ->withHeaders([ - 'x-test-header' => 1, - ]) - ->json('POST', route('sns'), $this->getNotificationPayload($payload)) - ->assertSee('OK'); + $this->withHeaders(array_merge($this->getHeadersForMessage($payload), [ + 'x-test-header' => 1, + ])) + ->json('POST', route('sns', ['certificate' => static::$certificate]), $payload) + ->assertSee('OK'); Event::assertNotDispatched(SnsSubscriptionConfirmation::class); @@ -82,12 +96,13 @@ public function test_custom_controller_confirmation() { Event::fake(); - $this - ->withHeaders([ - 'x-test-header' => 1, - ]) - ->json('POST', route('custom-sns', ['test' => 'some-string']), $this->getSubscriptionConfirmationPayload()) - ->assertSee('OK'); + $payload = $this->getSubscriptionConfirmationPayload(); + + $this->withHeaders(array_merge($this->getHeadersForMessage($payload), [ + 'x-test-header' => 1, + ])) + ->json('POST', route('custom-sns', ['test' => 'some-string', 'certificate' => static::$certificate]), $payload) + ->assertSee('OK'); Event::assertNotDispatched(CustomSnsEvent::class); @@ -108,17 +123,16 @@ public function test_custom_controller_notification() { Event::fake(); - $payload = json_encode([ + $payload = $this->getNotificationPayload([ 'test' => 1, 'sns' => true, ]); - $this - ->withHeaders([ - 'x-test-header' => 1, - ]) - ->json('POST', route('custom-sns', ['test' => 'some-string']), $this->getNotificationPayload($payload)) - ->assertSee('OK'); + $this->withHeaders(array_merge($this->getHeadersForMessage($payload), [ + 'x-test-header' => 1, + ])) + ->json('POST', route('custom-sns', ['test' => 'some-string', 'certificate' => static::$certificate]), $payload) + ->assertSee('OK'); Event::assertNotDispatched(CustomSubscriptionConfirmation::class); diff --git a/tests/TestCase.php b/tests/TestCase.php index c42590b..2c1f840 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,10 +2,55 @@ namespace Rennokki\LaravelSnsEvents\Tests; +use Aws\Sns\Message; +use Aws\Sns\MessageValidator; use Orchestra\Testbench\TestCase as Orchestra; class TestCase extends Orchestra { + /** + * Get the private key to sign the request. + * + * @var string + */ + protected static $privateKey; + + /** + * The certificate to sign the request. + * + * @var string + */ + protected static $certificate; + + /** + * @var string + */ + const VALID_CERT_URL = 'https://sns.us-west-2.amazonaws.com/bar.pem'; + + /** + * {@inheritdoc} + */ + public static function setUpBeforeClass(): void + { + self::$privateKey = openssl_pkey_new(); + + $csr = openssl_csr_new([], self::$privateKey); + + $x509 = openssl_csr_sign($csr, null, self::$privateKey, 1); + + openssl_x509_export($x509, self::$certificate); + + openssl_x509_free($x509); + } + + /** + * {@inheritdoc} + */ + public static function tearDownAfterClass(): void + { + openssl_pkey_free(self::$privateKey); + } + /** * Get package providers. * @@ -22,57 +67,103 @@ protected function getPackageProviders($app) } /** - * Define environment setup. - * - * @param \Illuminate\Foundation\Application $app - * - * @return void + * {@inheritdoc} */ protected function getEnvironmentSetUp($app) { $app['config']->set('app.key', '6rE9Nz59bGRbeMATftriyQjrpF7DcOQm'); } + /** + * Get the signature for the message. + * + * @param string $stringToSign + * @return string + */ + protected function getSignature($stringToSign) + { + openssl_sign($stringToSign, $signature, self::$privateKey); + + return base64_encode($signature); + } + /** * Get an example subscription payload for testing. * + * @param array $custom * @return array */ - protected function getSubscriptionConfirmationPayload(): array + protected function getSubscriptionConfirmationPayload(array $custom = []): array { - return [ + $validator = new MessageValidator; + + $message = array_merge([ 'Type' => 'SubscriptionConfirmation', 'MessageId' => '165545c9-2a5c-472c-8df2-7ff2be2b3b1b', 'Token' => '2336412f37...', 'TopicArn' => 'arn:aws:sns:us-west-2:123456789012:MyTopic', 'Message' => 'You have chosen to subscribe to the topic arn:aws:sns:us-west-2:123456789012:MyTopic.\nTo confirm the subscription, visit the SubscribeURL included in this message.', 'SubscribeURL' => 'https://example.com', - 'Timestamp' => '2012-04-26T20:45:04.751Z', + 'Timestamp' => now()->toDateTimeString(), 'SignatureVersion' => '1', - 'Signature' => 'EXAMPLEpH+DcEwjAPg8O9mY8dReBSwksfg2S7WKQcikcNKWLQjwu6A4VbeS0QHVCkhRS7fUQvi2egU3N858fiTDN6bkkOxYDVrY0Ad8L10Hs3zH81mtnPk5uvvolIC1CXGu43obcgFxeL3khZl8IKvO61GWB6jI9b5+gLPoBc1Q=', - 'SigningCertURL' => 'https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem', - ]; + 'Signature' => true, + 'SigningCertURL' => self::VALID_CERT_URL, + ], $custom); + + $message['Signature'] = $this->getSignature( + $validator->getStringToSign(new Message($message)) + ); + + return $message; } /** * Get an example notification payload for testing. * * @param string $payload + * @param array $custom * @return array */ - protected function getNotificationPayload($payload = ''): array + protected function getNotificationPayload($payload = '', array $custom = []): array { - return [ + $validator = new MessageValidator; + + $payload = json_encode($payload); + + $message = array_merge([ 'Type' => 'Notification', 'MessageId' => '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324', 'TopicArn' => 'arn:aws:sns:us-west-2:123456789012:MyTopic', 'Subject' => 'My First Message', 'Message' => "{$payload}", - 'Timestamp' => '2012-05-02T00:54:06.655Z', + 'Timestamp' => now()->toDateTimeString(), 'SignatureVersion' => '1', - 'Signature' => 'EXAMPLEw6JRN...', - 'SigningCertURL' => 'https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem', + 'Token' => '2336412f37...', + 'Signature' => true, + 'SigningCertURL' => self::VALID_CERT_URL, 'UnsubscribeURL' => 'https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96', + ], $custom); + + $message['Signature'] = $this->getSignature( + $validator->getStringToSign(new Message($message)) + ); + + return $message; + } + + /** + * Get the right headers for a SNS message. + * + * @param array $message + * @return array + */ + protected function getHeadersForMessage(array $message): array + { + return [ + 'X-AMZ-SNS-MESSAGE-TYPE' => $message['Type'], + 'X-AMZ-SNS-MESSAGE-ID' => $message['MessageId'], + 'X-AMZ-SNS-TOPIC-ARN' => $message['TopicArn'], + 'X-AMZ-SNS-SUBSCRIPTION-ARN' => "{$message['TopicArn']}:c9135db0-26c4-47ec-8998-413945fb5a96", ]; } } diff --git a/tests/routes/web.php b/tests/routes/web.php index 8d8fc6c..24aa3e9 100644 --- a/tests/routes/web.php +++ b/tests/routes/web.php @@ -2,7 +2,7 @@ use Illuminate\Support\Facades\Route; -Route::any('/sns', 'Rennokki\LaravelSnsEvents\Http\Controllers\SnsController@handle') +Route::any('/sns', 'Rennokki\LaravelSnsEvents\Tests\Controllers\SnsController@handle') ->name('sns'); Route::any('/sns-custom', 'Rennokki\LaravelSnsEvents\Tests\Controllers\CustomSnsController@handle')