diff --git a/CHANGELOG.md b/CHANGELOG.md index 6066d00..b42cc47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,13 @@ CHANGELOG * 0.3.0 (2013-xx-xx) - * Sugar and spice and everything nice: Added the Ratchet\App class for ease of use + * Added the `App` class to help making Ratchet so easy to use it's silly + * BC: Require hostname to do HTTP Host header match and do Origin HTTP header check, verify same name by default, helping prevent CSRF attacks * Added Symfony/2.2 based HTTP Router component to allowing for a single Ratchet server to handle multiple apps -> Ratchet\Http\Router * BC: Decoupled HTTP from WebSocket component -> Ratchet\Http\HttpServer * Updated dependency to React/0.3 + * BF: Single sub-protocol selection to conform with RFC6455 + * BF: Sanity checks on WAMP protocol to prevent errors * 0.2.7 (2013-06-09) diff --git a/src/Ratchet/App.php b/src/Ratchet/App.php index a3db257..5a00fdd 100644 --- a/src/Ratchet/App.php +++ b/src/Ratchet/App.php @@ -4,6 +4,7 @@ use React\EventLoop\LoopInterface; use React\EventLoop\Factory as LoopFactory; use React\Socket\Server as Reactor; use Ratchet\Http\HttpServerInterface; +use Ratchet\Http\OriginCheck; use Ratchet\Wamp\WampServerInterface; use Ratchet\Server\IoServer; use Ratchet\Server\FlashPolicy; @@ -77,11 +78,13 @@ class App { } /** - * @param string $path + * @param $path * @param ComponentInterface $controller - * @return \Symfony\Component\Routing\Route + * @param array $allowedOrigins An array of hosts allowed to connect (same host by default), [*] for any + * @param string $httpHost Override the $httpHost variable provided in the __construct + * @return ComponentInterface|WsServer */ - public function route($path, ComponentInterface $controller) { + public function route($path, ComponentInterface $controller, array $allowedOrigins = array(), $httpHost = null) { if ($controller instanceof HttpServerInterface || $controller instanceof WsServer) { $decorated = $controller; } elseif ($controller instanceof WampServerInterface) { @@ -92,7 +95,17 @@ class App { $decorated = $controller; } - $this->routes->add('rr-' . ++$this->_routeCounter, new Route($path, array('_controller' => $decorated), array(), array(), $this->httpHost)); + $httpHost = $httpHost ?: $this->httpHost; + + if (0 === count($allowedOrigins)) { + $allowedOrigins[] = $httpHost; + } + $allowedOrigins = array_values($allowedOrigins); + if ('*' !== $allowedOrigins[0]) { + $decorated = new OriginCheck($decorated, $allowedOrigins); + } + + $this->routes->add('rr-' . ++$this->_routeCounter, new Route($path, array('_controller' => $decorated), array('Origin' => $this->httpHost), array(), $httpHost)); return $decorated; } diff --git a/src/Ratchet/Http/OriginCheck.php b/src/Ratchet/Http/OriginCheck.php new file mode 100644 index 0000000..e043fd8 --- /dev/null +++ b/src/Ratchet/Http/OriginCheck.php @@ -0,0 +1,69 @@ +_component = $component; + $this->allowedOrigins += $allowed; + } + + /** + * {@inheritdoc} + */ + public function onOpen(ConnectionInterface $conn, RequestInterface $request = null) { + $origin = (string)$request->getHeader('Origin'); + + if (!in_array($origin, $this->allowedOrigins)) { + return $this->close($conn, 403); + } + + return $this->_component->onOpen($conn, $request); + } + + /** + * {@inheritdoc} + */ + function onMessage(ConnectionInterface $from, $msg) { + return $this->_component->onMessage($from, $msg); + } + + /** + * {@inheritdoc} + */ + function onClose(ConnectionInterface $conn) { + return $this->_component->onClose($conn); + } + + /** + * {@inheritdoc} + */ + function onError(ConnectionInterface $conn, \Exception $e) { + return $this->_component->onError($conn, $e); + } + + /** + * Close a connection with an HTTP response + * @param \Ratchet\ConnectionInterface $conn + * @param int $code HTTP status code + * @return null + */ + protected function close(ConnectionInterface $conn, $code = 400) { + $response = new Response($code, array( + 'X-Powered-By' => \Ratchet\VERSION + )); + + $conn->send((string)$response); + $conn->close(); + } +} \ No newline at end of file diff --git a/tests/helpers/Ratchet/AbstractMessageComponentTestCase.php b/tests/helpers/Ratchet/AbstractMessageComponentTestCase.php index 990e98c..2ab458e 100644 --- a/tests/helpers/Ratchet/AbstractMessageComponentTestCase.php +++ b/tests/helpers/Ratchet/AbstractMessageComponentTestCase.php @@ -16,7 +16,11 @@ abstract class AbstractMessageComponentTestCase extends \PHPUnit_Framework_TestC $this->_serv = new $decorator($this->_app); $this->_conn = $this->getMock('\Ratchet\ConnectionInterface'); - $this->_serv->onOpen($this->_conn); + $this->doOpen($this->_conn); + } + + protected function doOpen($conn) { + $this->_serv->onOpen($conn); } public function isExpectedConnection() { @@ -25,7 +29,7 @@ abstract class AbstractMessageComponentTestCase extends \PHPUnit_Framework_TestC public function testOpen() { $this->_app->expects($this->once())->method('onOpen')->with($this->isExpectedConnection()); - $this->_serv->onOpen($this->getMock('\Ratchet\ConnectionInterface')); + $this->doOpen($this->getMock('\Ratchet\ConnectionInterface')); } public function testOnClose() { @@ -38,4 +42,9 @@ abstract class AbstractMessageComponentTestCase extends \PHPUnit_Framework_TestC $this->_app->expects($this->once())->method('onError')->with($this->isExpectedConnection(), $e); $this->_serv->onError($this->_conn, $e); } + + public function passthroughMessageTest($value) { + $this->_app->expects($this->once())->method('onMessage')->with($this->isExpectedConnection(), $value); + $this->_serv->onMessage($this->_conn, $value); + } } \ No newline at end of file diff --git a/tests/unit/Http/OriginCheckTest.php b/tests/unit/Http/OriginCheckTest.php new file mode 100644 index 0000000..34db439 --- /dev/null +++ b/tests/unit/Http/OriginCheckTest.php @@ -0,0 +1,46 @@ +_reqStub = $this->getMock('Guzzle\Http\Message\RequestInterface'); + $this->_reqStub->expects($this->any())->method('getHeader')->will($this->returnValue('localhost')); + + parent::setUp(); + + $this->_serv->allowedOrigins[] = 'localhost'; + } + + protected function doOpen($conn) { + $this->_serv->onOpen($conn, $this->_reqStub); + } + + public function getConnectionClassString() { + return '\Ratchet\ConnectionInterface'; + } + + public function getDecoratorClassString() { + return '\Ratchet\Http\OriginCheck'; + } + + public function getComponentClassString() { + return '\Ratchet\Http\HttpServerInterface'; + } + + public function testCloseOnNonMatchingOrigin() { + $this->_serv->allowedOrigins = array('socketo.me'); + $this->_conn->expects($this->once())->method('close'); + + $this->_serv->onOpen($this->_conn, $this->_reqStub); + } + + public function testOnMessage() { + $this->passthroughMessageTest('Hello World!'); + } +}