Merge pull request #469 from beyondcode/refactor/cors

[2.x] Per-app CORS
This commit is contained in:
rennokki 2020-08-18 16:27:56 +03:00 committed by GitHub
commit 3b49bcd5cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 104 additions and 86 deletions

View File

@ -84,23 +84,12 @@ return [
'capacity' => null, 'capacity' => null,
'enable_client_messages' => false, 'enable_client_messages' => false,
'enable_statistics' => true, 'enable_statistics' => true,
'allowed_origins' => [
//
],
], ],
], ],
/*
|--------------------------------------------------------------------------
| Allowed Origins
|--------------------------------------------------------------------------
|
| If not empty, you can whitelist certain origins that will be allowed
| to connect to the websocket server.
|
*/
'allowed_origins' => [
//
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Maximum Request Size | Maximum Request Size

View File

@ -74,6 +74,7 @@ You may add additional apps in your `config/websockets.php` file.
'capacity' => null, 'capacity' => null,
'enable_client_messages' => false, 'enable_client_messages' => false,
'enable_statistics' => true, 'enable_statistics' => true,
'allowed_origins' => [],
], ],
], ],
``` ```

View File

@ -33,6 +33,9 @@ class App
/** @var bool */ /** @var bool */
public $statisticsEnabled = true; public $statisticsEnabled = true;
/** @var array */
public $allowedOrigins = [];
public static function findById($appId) public static function findById($appId)
{ {
return app(AppManager::class)->findById($appId); return app(AppManager::class)->findById($appId);
@ -106,4 +109,11 @@ class App
return $this; return $this;
} }
public function setAllowedOrigins(array $allowedOrigins)
{
$this->allowedOrigins = $allowedOrigins;
return $this;
}
} }

View File

@ -78,7 +78,8 @@ class ConfigAppManager implements AppManager
$app $app
->enableClientMessages($appAttributes['enable_client_messages']) ->enableClientMessages($appAttributes['enable_client_messages'])
->enableStatistics($appAttributes['enable_statistics']) ->enableStatistics($appAttributes['enable_statistics'])
->setCapacity($appAttributes['capacity'] ?? null); ->setCapacity($appAttributes['capacity'] ?? null)
->setAllowedOrigins($appAttributes['allowed_origins'] ?? []);
return $app; return $app;
} }

View File

@ -1,60 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Server;
use Psr\Http\Message\RequestInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Http\CloseResponseTrait;
use Ratchet\Http\HttpServerInterface;
use Ratchet\MessageComponentInterface;
class OriginCheck implements HttpServerInterface
{
use CloseResponseTrait;
/** @var \Ratchet\MessageComponentInterface */
protected $_component;
protected $allowedOrigins = [];
public function __construct(MessageComponentInterface $component, array $allowedOrigins = [])
{
$this->_component = $component;
$this->allowedOrigins = $allowedOrigins;
}
public function onOpen(ConnectionInterface $connection, RequestInterface $request = null)
{
if ($request->hasHeader('Origin')) {
$this->verifyOrigin($connection, $request);
}
return $this->_component->onOpen($connection, $request);
}
public function onMessage(ConnectionInterface $from, $msg)
{
return $this->_component->onMessage($from, $msg);
}
public function onClose(ConnectionInterface $connection)
{
return $this->_component->onClose($connection);
}
public function onError(ConnectionInterface $connection, \Exception $e)
{
return $this->_component->onError($connection, $e);
}
protected function verifyOrigin(ConnectionInterface $connection, RequestInterface $request)
{
$header = (string) $request->getHeader('Origin')[0];
$origin = parse_url($header, PHP_URL_HOST) ?: $header;
if (! empty($this->allowedOrigins) && ! in_array($origin, $this->allowedOrigins)) {
return $this->close($connection, 403);
}
}
}

View File

@ -79,11 +79,9 @@ class WebSocketServerFactory
$socket = new SecureServer($socket, $this->loop, config('websockets.ssl')); $socket = new SecureServer($socket, $this->loop, config('websockets.ssl'));
} }
$urlMatcher = new UrlMatcher($this->routes, new RequestContext); $app = new Router(
new UrlMatcher($this->routes, new RequestContext)
$router = new Router($urlMatcher); );
$app = new OriginCheck($router, config('websockets.allowed_origins', []));
$httpServer = new HttpServer($app, config('websockets.max_request_size_in_kb') * 1024); $httpServer = new HttpServer($app, config('websockets.max_request_size_in_kb') * 1024);

View File

@ -0,0 +1,12 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Exceptions;
class OriginNotAllowed extends WebSocketException
{
public function __construct(string $appKey)
{
$this->message = "The origin is not allowed for `{$appKey}`.";
$this->code = 4009;
}
}

View File

@ -8,6 +8,7 @@ use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger;
use BeyondCode\LaravelWebSockets\QueryParameters; use BeyondCode\LaravelWebSockets\QueryParameters;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\ConnectionsOverCapacity; use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\ConnectionsOverCapacity;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\OriginNotAllowed;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\UnknownAppKey; use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\UnknownAppKey;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\WebSocketException; use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\WebSocketException;
use BeyondCode\LaravelWebSockets\WebSockets\Messages\PusherMessageFactory; use BeyondCode\LaravelWebSockets\WebSockets\Messages\PusherMessageFactory;
@ -30,6 +31,7 @@ class WebSocketHandler implements MessageComponentInterface
{ {
$this $this
->verifyAppKey($connection) ->verifyAppKey($connection)
->verifyOrigin($connection)
->limitConcurrentConnections($connection) ->limitConcurrentConnections($connection)
->generateSocketId($connection) ->generateSocketId($connection)
->establishConnection($connection); ->establishConnection($connection);
@ -77,6 +79,23 @@ class WebSocketHandler implements MessageComponentInterface
return $this; return $this;
} }
protected function verifyOrigin(ConnectionInterface $connection)
{
if (! $connection->app->allowedOrigins) {
return $this;
}
$header = (string) ($connection->httpRequest->getHeader('Origin')[0] ?? null);
$origin = parse_url($header, PHP_URL_HOST) ?: $header;
if (! $header || ! in_array($origin, $connection->app->allowedOrigins)) {
throw new OriginNotAllowed($connection->app->key);
}
return $this;
}
protected function limitConcurrentConnections(ConnectionInterface $connection) protected function limitConcurrentConnections(ConnectionInterface $connection)
{ {
if (! is_null($capacity = $connection->app->capacity)) { if (! is_null($capacity = $connection->app->capacity)) {

View File

@ -22,7 +22,7 @@ class ConfigAppManagerTest extends TestCase
{ {
$apps = $this->appManager->all(); $apps = $this->appManager->all();
$this->assertCount(1, $apps); $this->assertCount(2, $apps);
/** @var $app */ /** @var $app */
$app = $apps[0]; $app = $apps[0];

View File

@ -5,6 +5,7 @@ namespace BeyondCode\LaravelWebSockets\Tests;
use BeyondCode\LaravelWebSockets\Apps\App; use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message; use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\ConnectionsOverCapacity; use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\ConnectionsOverCapacity;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\OriginNotAllowed;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\UnknownAppKey; use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\UnknownAppKey;
class ConnectionTest extends TestCase class ConnectionTest extends TestCase
@ -14,7 +15,7 @@ class ConnectionTest extends TestCase
{ {
$this->expectException(UnknownAppKey::class); $this->expectException(UnknownAppKey::class);
$this->pusherServer->onOpen($this->getWebSocketConnection('/?appKey=test')); $this->pusherServer->onOpen($this->getWebSocketConnection('test'));
} }
/** @test */ /** @test */
@ -65,4 +66,38 @@ class ConnectionTest extends TestCase
$connection->assertSentEvent('pusher:pong'); $connection->assertSentEvent('pusher:pong');
} }
/** @test */
public function origin_validation_should_fail_for_no_origin()
{
$this->expectException(OriginNotAllowed::class);
$connection = $this->getWebSocketConnection('TestOrigin');
$this->pusherServer->onOpen($connection);
$connection->assertSentEvent('pusher:connection_established');
}
/** @test */
public function origin_validation_should_fail_for_wrong_origin()
{
$this->expectException(OriginNotAllowed::class);
$connection = $this->getWebSocketConnection('TestOrigin', ['Origin' => 'https://google.ro']);
$this->pusherServer->onOpen($connection);
$connection->assertSentEvent('pusher:connection_established');
}
/** @test */
public function origin_validation_should_pass_for_the_right_origin()
{
$connection = $this->getWebSocketConnection('TestOrigin', ['Origin' => 'https://test.origin.com']);
$this->pusherServer->onOpen($connection);
$connection->assertSentEvent('pusher:connection_established');
}
} }

View File

@ -70,6 +70,19 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'capacity' => null, 'capacity' => null,
'enable_client_messages' => false, 'enable_client_messages' => false,
'enable_statistics' => true, 'enable_statistics' => true,
'allowed_origins' => [],
],
[
'name' => 'Origin Test App',
'id' => '1234',
'key' => 'TestOrigin',
'secret' => 'TestSecret',
'capacity' => null,
'enable_client_messages' => false,
'enable_statistics' => true,
'allowed_origins' => [
'test.origin.com',
],
], ],
]); ]);
@ -107,20 +120,20 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
} }
} }
protected function getWebSocketConnection(string $url = '/?appKey=TestKey'): Connection protected function getWebSocketConnection(string $appKey = 'TestKey', array $headers = []): Connection
{ {
$connection = new Connection(); $connection = new Connection();
$connection->httpRequest = new Request('GET', $url); $connection->httpRequest = new Request('GET', "/?appKey={$appKey}", $headers);
return $connection; return $connection;
} }
protected function getConnectedWebSocketConnection(array $channelsToJoin = [], string $url = '/?appKey=TestKey'): Connection protected function getConnectedWebSocketConnection(array $channelsToJoin = [], string $appKey = 'TestKey', array $headers = []): Connection
{ {
$connection = new Connection(); $connection = new Connection();
$connection->httpRequest = new Request('GET', $url); $connection->httpRequest = new Request('GET', "/?appKey={$appKey}", $headers);
$this->pusherServer->onOpen($connection); $this->pusherServer->onOpen($connection);