From 73cc140d76e803b151fc2dd2e4eb3eb784a82ee2 Mon Sep 17 00:00:00 2001 From: Joe Dixon Date: Wed, 25 Sep 2024 18:08:49 +0300 Subject: [PATCH] [1.x] Implements API signature verification (#252) * add signature validation * update query string sorting * formatting * add tests --- .../Pusher/Http/Controllers/Controller.php | 4 +- .../Pusher/Reverb/ChannelControllerTest.php | 7 +++ .../Reverb/ChannelUsersControllerTest.php | 14 +++-- .../Pusher/Reverb/ChannelsControllerTest.php | 7 +++ .../Reverb/ConnectionsControllerTest.php | 7 +++ .../Reverb/EventsBatchControllerTest.php | 12 +++++ .../Pusher/Reverb/EventsControllerTest.php | 12 ++++- .../Protocols/Pusher/Reverb/ServerTest.php | 2 +- .../Reverb/UsersTerminateControllerTest.php | 8 ++- tests/ReverbTestCase.php | 53 ++++++++----------- 10 files changed, 87 insertions(+), 39 deletions(-) diff --git a/src/Protocols/Pusher/Http/Controllers/Controller.php b/src/Protocols/Pusher/Http/Controllers/Controller.php index a583cf53..f92643fc 100644 --- a/src/Protocols/Pusher/Http/Controllers/Controller.php +++ b/src/Protocols/Pusher/Http/Controllers/Controller.php @@ -48,6 +48,7 @@ public function verify(RequestInterface $request, Connection $connection, $appId $this->setApplication($appId); $this->setChannels(); + $this->verifySignature($request); } /** @@ -100,8 +101,9 @@ protected function verifySignature(RequestInterface $request): void ]); $signature = hash_hmac('sha256', $signature, $this->application->secret()); + $authSignature = $this->query['auth_signature'] ?? ''; - if ($signature !== $this->query['auth_signature']) { + if ($signature !== $authSignature) { throw new HttpException(401, 'Authentication signature invalid.'); } } diff --git a/tests/Feature/Protocols/Pusher/Reverb/ChannelControllerTest.php b/tests/Feature/Protocols/Pusher/Reverb/ChannelControllerTest.php index ea810525..5bf6dc57 100644 --- a/tests/Feature/Protocols/Pusher/Reverb/ChannelControllerTest.php +++ b/tests/Feature/Protocols/Pusher/Reverb/ChannelControllerTest.php @@ -1,6 +1,7 @@ getHeader('Content-Length'))->toBe(['40']); }); + +it('fails when using an invalid signature', function () { + $response = await($this->request('channels/test-channel-one?info=user_count,subscription_count,cache')); + + expect($response->getStatusCode())->toBe(401); +})->throws(ResponseException::class, exceptionCode: 401); diff --git a/tests/Feature/Protocols/Pusher/Reverb/ChannelUsersControllerTest.php b/tests/Feature/Protocols/Pusher/Reverb/ChannelUsersControllerTest.php index 671b9b87..2fc46ae1 100644 --- a/tests/Feature/Protocols/Pusher/Reverb/ChannelUsersControllerTest.php +++ b/tests/Feature/Protocols/Pusher/Reverb/ChannelUsersControllerTest.php @@ -13,11 +13,11 @@ it('returns an error when presence channel not provided', function () { subscribe('test-channel'); await($this->signedRequest('channels/test-channel/users')); -})->throws(ResponseException::class); +})->throws(ResponseException::class, exceptionCode: 400); it('returns an error when unoccupied channel provided', function () { await($this->signedRequest('channels/presence-test-channel/users')); -})->throws(ResponseException::class); +})->throws(ResponseException::class, exceptionCode: 404); it('returns the user data', function () { $channel = app(ChannelManager::class) @@ -53,13 +53,13 @@ subscribe('test-channel'); await($this->signedRequest('channels/test-channel/users')); -})->throws(ResponseException::class); +})->throws(ResponseException::class, exceptionCode: 400); it('returns an error when gathering unoccupied channel provided', function () { $this->usingRedis(); await($this->signedRequest('channels/presence-test-channel/users')); -})->throws(ResponseException::class); +})->throws(ResponseException::class, exceptionCode: 404); it('can send the content-length header', function () { $channel = app(ChannelManager::class) @@ -120,3 +120,9 @@ expect($response->getHeader('Content-Length'))->toBe(['38']); }); + +it('fails when using an invalid signature', function () { + $response = await($this->request('channels/presence-test-channel/users')); + + expect($response->getStatusCode())->toBe(401); +})->throws(ResponseException::class, exceptionCode: 401); diff --git a/tests/Feature/Protocols/Pusher/Reverb/ChannelsControllerTest.php b/tests/Feature/Protocols/Pusher/Reverb/ChannelsControllerTest.php index 1eee2f75..1634e8f2 100644 --- a/tests/Feature/Protocols/Pusher/Reverb/ChannelsControllerTest.php +++ b/tests/Feature/Protocols/Pusher/Reverb/ChannelsControllerTest.php @@ -2,6 +2,7 @@ use Illuminate\Support\Arr; use Laravel\Reverb\Tests\ReverbTestCase; +use React\Http\Message\ResponseException; use function React\Async\await; @@ -122,3 +123,9 @@ expect($response->getHeader('Content-Length'))->toBe(['81']); }); + +it('fails when using an invalid signature', function () { + $response = await($this->request('channels?info=user_count')); + + expect($response->getStatusCode())->toBe(401); +})->throws(ResponseException::class, exceptionCode: 401); diff --git a/tests/Feature/Protocols/Pusher/Reverb/ConnectionsControllerTest.php b/tests/Feature/Protocols/Pusher/Reverb/ConnectionsControllerTest.php index 675c4be5..cd9b9146 100644 --- a/tests/Feature/Protocols/Pusher/Reverb/ConnectionsControllerTest.php +++ b/tests/Feature/Protocols/Pusher/Reverb/ConnectionsControllerTest.php @@ -1,6 +1,7 @@ getHeader('Content-Length'))->toBe(['17']); }); + +it('fails when using an invalid signature', function () { + $response = await($this->request('connections')); + + expect($response->getStatusCode())->toBe(401); +})->throws(ResponseException::class, exceptionCode: 401); diff --git a/tests/Feature/Protocols/Pusher/Reverb/EventsBatchControllerTest.php b/tests/Feature/Protocols/Pusher/Reverb/EventsBatchControllerTest.php index efa6c631..ee6a4554 100644 --- a/tests/Feature/Protocols/Pusher/Reverb/EventsBatchControllerTest.php +++ b/tests/Feature/Protocols/Pusher/Reverb/EventsBatchControllerTest.php @@ -171,3 +171,15 @@ expect($response->getHeader('Content-Length'))->toBe(['12']); }); + +it('fails when using an invalid signature', function () { + $response = await($this->postRequest('batch_events', ['batch' => [ + [ + 'name' => 'NewEvent', + 'channel' => 'test-channel', + 'data' => json_encode(['some' => 'data']), + ], + ]])); + + expect($response->getStatusCode())->toBe(401); +})->throws(ResponseException::class, exceptionCode: 401); diff --git a/tests/Feature/Protocols/Pusher/Reverb/EventsControllerTest.php b/tests/Feature/Protocols/Pusher/Reverb/EventsControllerTest.php index a34487c0..a747e286 100644 --- a/tests/Feature/Protocols/Pusher/Reverb/EventsControllerTest.php +++ b/tests/Feature/Protocols/Pusher/Reverb/EventsControllerTest.php @@ -182,7 +182,7 @@ 'name' => 'NewEvent', 'channel' => 'test-channel', 'data' => json_encode([str_repeat('a', 10_100)]), - ], appId: '654321')); + ], appId: '654321', key: 'reverb-key-2', secret: 'reverb-secret-2')); expect($response->getStatusCode())->toBe(200); expect($response->getBody()->getContents())->toBe('{}'); @@ -207,3 +207,13 @@ expect($response->getHeader('Content-Length'))->toBe(['2']); }); + +it('fails when using an invalid signature', function () { + $response = await($this->postRequest('events', [ + 'name' => 'NewEvent', + 'channel' => 'test-channel', + 'data' => json_encode(['some' => 'data']), + ])); + + expect($response->getStatusCode())->toBe(401); +})->throws(ResponseException::class, exceptionCode: 401); diff --git a/tests/Feature/Protocols/Pusher/Reverb/ServerTest.php b/tests/Feature/Protocols/Pusher/Reverb/ServerTest.php index 3b7cb9fa..86a5b8dc 100644 --- a/tests/Feature/Protocols/Pusher/Reverb/ServerTest.php +++ b/tests/Feature/Protocols/Pusher/Reverb/ServerTest.php @@ -465,7 +465,7 @@ 'name' => 'NewEvent', 'channel' => 'test-channel', 'data' => json_encode([str_repeat('a', 150_000)]), - ], appId: '654321')); + ], appId: '654321', key: 'reverb-key-2', secret: 'reverb-secret-2')); expect($response->getStatusCode())->toBe(200); expect($response->getBody()->getContents())->toBe('{}'); diff --git a/tests/Feature/Protocols/Pusher/Reverb/UsersTerminateControllerTest.php b/tests/Feature/Protocols/Pusher/Reverb/UsersTerminateControllerTest.php index 99851799..5012966b 100644 --- a/tests/Feature/Protocols/Pusher/Reverb/UsersTerminateControllerTest.php +++ b/tests/Feature/Protocols/Pusher/Reverb/UsersTerminateControllerTest.php @@ -9,7 +9,7 @@ it('returns an error when connection cannot be found', function () { await($this->signedPostRequest('channels/users/not-a-user/terminate_connections')); -})->throws(ResponseException::class); +})->throws(ResponseException::class, exceptionCode: 404); it('unsubscribes from all channels and terminates a user', function () { $connection = connect(); @@ -54,3 +54,9 @@ expect(collect(channels()->all())->get('test-channel-two')->connections())->toHaveCount(1); expect($response->getHeader('Content-Length'))->toBe(['2']); }); + +it('fails when using an invalid signature', function () { + $response = await($this->postRequest('users/987/terminate_connections')); + + expect($response->getStatusCode())->toBe(401); +})->throws(ResponseException::class, exceptionCode: 401); diff --git a/tests/ReverbTestCase.php b/tests/ReverbTestCase.php index a8ee272b..00b09c96 100644 --- a/tests/ReverbTestCase.php +++ b/tests/ReverbTestCase.php @@ -160,16 +160,29 @@ public function requestWithoutAppId(string $path, string $method = 'GET', mixed /** * Send a signed request to the server. */ - public function signedRequest(string $path, string $method = 'GET', mixed $data = '', string $host = '0.0.0.0', string $port = '8080', string $appId = '123456'): PromiseInterface + public function signedRequest(string $path, string $method = 'GET', mixed $data = '', string $host = '0.0.0.0', string $port = '8080', string $appId = '123456', string $key = 'reverb-key', string $secret = 'reverb-secret'): PromiseInterface { - $hash = md5(json_encode($data)); $timestamp = time(); - $query = "auth_key=reverb-key&auth_timestamp={$timestamp}&auth_version=1.0&body_md5={$hash}"; - $string = "POST\n/apps/{$appId}/{$path}\n$query"; - $signature = hash_hmac('sha256', $string, 'reverb-secret'); - $path = Str::contains($path, '?') ? "{$path}&{$query}" : "{$path}?{$query}"; - return $this->request("{$path}&auth_signature={$signature}", $method, $data, $host, $port, $appId); + $query = Str::contains($path, '?') ? Str::after($path, '?') : ''; + $auth = "auth_key={$key}&auth_timestamp={$timestamp}&auth_version=1.0"; + $query = $query ? "{$query}&{$auth}" : $auth; + + $query = explode('&', $query); + sort($query); + $query = implode('&', $query); + + $path = Str::before($path, '?'); + + if ($data) { + $hash = md5(json_encode($data)); + $query .= "&body_md5={$hash}"; + } + + $string = "{$method}\n/apps/{$appId}/{$path}\n$query"; + $signature = hash_hmac('sha256', $string, $secret); + + return $this->request("{$path}?{$query}&auth_signature={$signature}", $method, $data, $host, $port, $appId); } /** @@ -183,30 +196,8 @@ public function postRequest(string $path, ?array $data = [], string $host = '0.0 /** * Send a signed POST request to the server. */ - public function signedPostRequest(string $path, ?array $data = [], string $host = '0.0.0.0', string $port = '8080', string $appId = '123456'): PromiseInterface + public function signedPostRequest(string $path, ?array $data = [], string $host = '0.0.0.0', string $port = '8080', string $appId = '123456', $key = 'reverb-key', $secret = 'reverb-secret'): PromiseInterface { - $hash = md5(json_encode($data)); - $timestamp = time(); - $query = "auth_key=reverb-key&auth_timestamp={$timestamp}&auth_version=1.0&body_md5={$hash}"; - $string = "POST\n/apps/{$appId}/{$path}\n$query"; - $signature = hash_hmac('sha256', $string, 'reverb-secret'); - - return $this->postRequest("{$path}?{$query}&auth_signature={$signature}", $data, $host, $port, $appId); - } - - /** - * Send a signed GET request to the server. - */ - public function getWithSignature(string $path, array $data = [], string $host = '0.0.0.0', string $port = '8080', string $appId = '123456'): PromiseInterface - { - $hash = md5(json_encode($data)); - $timestamp = time(); - $query = "auth_key=reverb-key&auth_timestamp={$timestamp}&auth_version=1.0&body_md5={$hash}"; - $string = "POST\n/apps/{$appId}/{$path}\n$query"; - $signature = hash_hmac('sha256', $string, 'reverb-secret'); - - $path = Str::contains($path, '?') ? "{$path}&{$query}" : "{$path}?{$query}"; - - return $this->request("{$path}&auth_signature={$signature}", 'GET', '', $host, $port, $appId); + return $this->signedRequest($path, 'POST', $data, $host, $port, $appId, $key, $secret); } }