From 935866c036fb93e0c29d030c446719d884305a57 Mon Sep 17 00:00:00 2001 From: Chris Boden Date: Sat, 19 May 2012 23:43:30 -0400 Subject: [PATCH] [WebSocket] Refactoring Separated handshake negotiation into its own class `HandshakeNegotiator` deals with Request/Response classes These changes are geared towards separate responsibility Refs #29 --- src/Ratchet/WebSocket/HandshakeNegotiator.php | 140 ++++++++++++++++ src/Ratchet/WebSocket/Version/Hixie76.php | 7 +- src/Ratchet/WebSocket/Version/HyBi10.php | 6 +- src/Ratchet/WebSocket/Version/RFC6455.php | 11 +- .../WebSocket/Version/VersionInterface.php | 16 +- src/Ratchet/WebSocket/WsConnection.php | 38 ++++- src/Ratchet/WebSocket/WsServer.php | 149 ++++++------------ .../WebSocket/HandshakeNegotiatorTest.php | 103 ++++++++++++ 8 files changed, 355 insertions(+), 115 deletions(-) create mode 100644 src/Ratchet/WebSocket/HandshakeNegotiator.php create mode 100644 tests/Ratchet/Tests/WebSocket/HandshakeNegotiatorTest.php diff --git a/src/Ratchet/WebSocket/HandshakeNegotiator.php b/src/Ratchet/WebSocket/HandshakeNegotiator.php new file mode 100644 index 0000000..3f19bf1 --- /dev/null +++ b/src/Ratchet/WebSocket/HandshakeNegotiator.php @@ -0,0 +1,140 @@ +enableVersion(new Version\RFC6455); + $this->enableVersion(new Version\HyBi10); + $this->enableVersion(new Version\Hixie76); + } + } + + /** + * @param WsConnection + */ + public function onOpen(WsConnection $conn) { + $conn->WebSocket->handshakeBuffer = ''; + } + + /** + * @param WsConnection + * @param string Data stream to buffer + * @return Guzzle\Http\Message\Response|null Response object if it's done parsing, null if there's more to be buffered + * @throws HttpException + */ + public function onData(WsConnection $conn, $data) { + $conn->WebSocket->handshakeBuffer .= $data; + + if (mb_strlen($conn->WebSocket->handshakeBuffer, '8bit') >= (int)$this->maxSize) { + return new Response(413, array('X-Powered-By' => \Ratchet\VERSION)); + } + + if ($this->isEom($conn->WebSocket->handshakeBuffer)) { + $conn->WebSocket->request = RequestFactory::getInstance()->fromMessage($conn->WebSocket->handshakeBuffer); + + if (null === ($version = $this->getVersion($conn->WebSocket->request))) { + return new Response(400, array( + 'Sec-WebSocket-Version' => $this->getSupportedVersionString() + , 'X-Powered-By' => \Ratchet\VERSION + )); + } + + // TODO: confirm message is buffered + // Hixie requires the body to complete the handshake (6 characters long) + // Update VersionInterface to check for this, ::canHandshake() maybe + // return if can't, continue buffering + + $response = $version->handshake($conn->WebSocket->request); + $response->setHeader('X-Powered-By', \Ratchet\VERSION); + + $conn->setVersion($version); + unset($conn->WebSocket->handshakeBuffer); + + return $response; + } + } + + /** + * Determine if the message has been buffered as per the HTTP specification + * @param string + * @return boolean + */ + public function isEom($message) { + return (static::EOM === substr($message, 0 - strlen(static::EOM))); + } + + /** + * Get the protocol negotiator for the request, if supported + * @param Guzzle\Http\Message\RequestInterface + * @return Ratchet\WebSocket\Version\VersionInterface + */ + public function getVersion(RequestInterface $request) { + foreach ($this->versions as $version) { + if ($version->isProtocol($request)) { + return $version; + } + } + } + + /** + * Enable support for a specific version of the WebSocket protocol + * @param Ratchet\WebSocket\Vesion\VersionInterface + * @return HandshakeNegotiator + */ + public function enableVersion(VersionInterface $version) { + $this->versions[$version->getVersionNumber()] = $version; + + if (empty($this->versionString)) { + $this->versionString = (string)$version->getVersionNumber(); + } else { + $this->versionString .= ", {$version->getVersionNumber()}"; + } + + return $this; + } + + /** + * Disable support for a specific WebSocket protocol version + * @param int The version ID to un-support + * @return HandshakeNegotiator + */ + public function disableVersion($versionId) { + unset($this->versions[$versionId]); + + $this->versionString = ''; + + foreach ($this->versions as $id => $object) { + $this->versionString .= "{$id}, "; + } + $this->versionString = substr($this->versionString, 0, -2); + + return $this; + } + + /** + * Get a string of version numbers supported (comma delimited) + * @return string + */ + public function getSupportedVersionString() { + return $this->versionString; + } +} \ No newline at end of file diff --git a/src/Ratchet/WebSocket/Version/Hixie76.php b/src/Ratchet/WebSocket/Version/Hixie76.php index 941eec4..1692eb0 100644 --- a/src/Ratchet/WebSocket/Version/Hixie76.php +++ b/src/Ratchet/WebSocket/Version/Hixie76.php @@ -12,17 +12,20 @@ use Guzzle\Http\Message\Response; * man-in-the-middle attack on 10%-15% of the people who saw their ad who had a browser (currently only Safari) supporting the Hixie76 protocol. * This was exploited by taking advantage of proxy servers in front of the user who ignored some HTTP headers in the handshake * The Hixie76 is currently implemented by Safari - * Handshake from Andrea Giammarchi (http://webreflection.blogspot.com/2010/06/websocket-handshake-76-simplified.html) * @link http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-76 */ class Hixie76 implements VersionInterface { /** * {@inheritdoc} */ - public static function isProtocol(RequestInterface $request) { + public function isProtocol(RequestInterface $request) { return !(null === $request->getHeader('Sec-WebSocket-Key2', true)); } + public function getVersionNumber() { + return 0; + } + /** * @param Guzzle\Http\Message\RequestInterface * @return Guzzle\Http\Message\Response diff --git a/src/Ratchet/WebSocket/Version/HyBi10.php b/src/Ratchet/WebSocket/Version/HyBi10.php index 734d7e4..bbf3dc4 100644 --- a/src/Ratchet/WebSocket/Version/HyBi10.php +++ b/src/Ratchet/WebSocket/Version/HyBi10.php @@ -3,11 +3,15 @@ namespace Ratchet\WebSocket\Version; use Guzzle\Http\Message\RequestInterface; class HyBi10 extends RFC6455 { - public static function isProtocol(RequestInterface $request) { + public function isProtocol(RequestInterface $request) { $version = (int)$request->getHeader('Sec-WebSocket-Version', -1); return ($version >= 6 && $version < 13); } + public function getVersionNumber() { + return 6; + } + /** * @return HyBi10\Message * / diff --git a/src/Ratchet/WebSocket/Version/RFC6455.php b/src/Ratchet/WebSocket/Version/RFC6455.php index 50d7632..1be2246 100644 --- a/src/Ratchet/WebSocket/Version/RFC6455.php +++ b/src/Ratchet/WebSocket/Version/RFC6455.php @@ -23,9 +23,14 @@ class RFC6455 implements VersionInterface { /** * {@inheritdoc} */ - public static function isProtocol(RequestInterface $request) { + public function isProtocol(RequestInterface $request) { $version = (int)$request->getHeader('Sec-WebSocket-Version', -1); - return (13 === $version); + + return ($this->getVersionNumber() === $version); + } + + public function getVersionNumber() { + return 13; } /** @@ -43,7 +48,7 @@ class RFC6455 implements VersionInterface { , 'Sec-WebSocket-Accept' => $this->sign($request->getHeader('Sec-WebSocket-Key')) ); - return new Response('101', $headers); + return new Response(101, $headers); } /** diff --git a/src/Ratchet/WebSocket/Version/VersionInterface.php b/src/Ratchet/WebSocket/Version/VersionInterface.php index 1602641..d0839b9 100644 --- a/src/Ratchet/WebSocket/Version/VersionInterface.php +++ b/src/Ratchet/WebSocket/Version/VersionInterface.php @@ -3,10 +3,7 @@ namespace Ratchet\WebSocket\Version; use Guzzle\Http\Message\RequestInterface; /** - * Despite the version iterations of WebInterface the actions they go through are similar - * This standardizes how the server handles communication with each protocol version - * @todo Need better naming conventions...newMessage and newFrame are for reading incoming framed messages (action is unframing) - * The current method names suggest you could create a new message/frame to send, which they can not do + * A standard interface for interacting with the various version of the WebSocket protocol */ interface VersionInterface { /** @@ -15,15 +12,20 @@ interface VersionInterface { * @return bool * @throws UnderflowException If the protocol thinks the headers are still fragmented */ - static function isProtocol(RequestInterface $request); + function isProtocol(RequestInterface $request); + + /** + * Although the version has a name associated with it the integer returned is the proper identification + * @return int + */ + function getVersionNumber(); /** * Perform the handshake and return the response headers * @param Guzzle\Http\Message\RequestInterface - * @return array|string + * @return Guzzle\Http\Message\Response * @throws InvalidArgumentException If the HTTP handshake is mal-formed * @throws UnderflowException If the message hasn't finished buffering (not yet implemented, theoretically will only happen with Hixie version) - * @todo Change param to accept a Guzzle RequestInterface object */ function handshake(RequestInterface $request); diff --git a/src/Ratchet/WebSocket/WsConnection.php b/src/Ratchet/WebSocket/WsConnection.php index c0f127b..a346fa9 100644 --- a/src/Ratchet/WebSocket/WsConnection.php +++ b/src/Ratchet/WebSocket/WsConnection.php @@ -1,16 +1,30 @@ WebSocket->version->frame($data, false); + public function __construct(ConnectionInterface $conn) { + parent::__construct($conn); + + $this->WebSocket = new \StdClass; + } + + public function send($data) { + if ($this->hasVersion()) { + // need frame caching + $data = $this->WebSocket->version->frame($data, false); + } $this->getConnection()->send($data); } @@ -30,4 +44,22 @@ class WsConnection extends AbstractConnectionDecorator { public function pong() { } + + /** + * @return boolean + */ + public function hasVersion() { + return (null === $this->version); + } + + /** + * Set the WebSocket protocol version to communicate with + * @param Ratchet\WebSocket\Version\VersionInterface + * @internal + */ + public function setVersion(VersionInterface $version) { + $this->WebSocket->version = $version; + + return $this; + } } \ No newline at end of file diff --git a/src/Ratchet/WebSocket/WsServer.php b/src/Ratchet/WebSocket/WsServer.php index b69119c..619ae9e 100644 --- a/src/Ratchet/WebSocket/WsServer.php +++ b/src/Ratchet/WebSocket/WsServer.php @@ -13,6 +13,13 @@ use Ratchet\WebSocket\Guzzle\Http\Message\RequestFactory; * @link http://dev.w3.org/html5/websockets/ */ class WsServer implements MessageComponentInterface { + /** + * Negotiates upgrading the HTTP connection to a WebSocket connection + * It contains useful configuration properties and methods + * @var HandshakeNegotiator + */ + public $handshaker; + /** * Decorated component * @var Ratchet\MessageComponentInterface|WsServerInterface @@ -24,6 +31,11 @@ class WsServer implements MessageComponentInterface { */ protected $connections; + /** + * @var MessageParser + */ + protected $messager; + /** * Re-entrant instances of protocol version classes * @internal @@ -53,6 +65,12 @@ class WsServer implements MessageComponentInterface { * @param Ratchet\MessageComponentInterface Your application to run with WebSockets */ public function __construct(MessageComponentInterface $component) { + // This will be enabled shortly, causing problems + // mb_internal_encoding('UTF-8'); + + $this->handshaker = new HandshakeNegotiator; + $this->messager = new MessageParser; + $this->_decorating = $component; $this->connections = new \SplObjectStorage; } @@ -61,46 +79,50 @@ class WsServer implements MessageComponentInterface { * {@inheritdoc} */ public function onOpen(ConnectionInterface $conn) { - $conn->WebSocket = new \stdClass; - $conn->WebSocket->handshake = false; - $conn->WebSocket->headers = ''; + $wsConn = new WsConnection($conn); + + $this->connections->attach($conn, $wsConn); + + $this->handshaker->onOpen($wsConn); + + $conn->WebSocket->established = false; } /** - * Do handshake, frame/unframe messages coming/going in stack * {@inheritdoc} */ public function onMessage(ConnectionInterface $from, $msg) { - if (true !== $from->WebSocket->handshake) { - if (!isset($from->WebSocket->version)) { - $from->WebSocket->headers .= $msg; - if (!$this->isMessageComplete($from->WebSocket->headers)) { - return; - } + $conn = $this->connections[$from]; - $headers = RequestFactory::getInstance()->fromMessage($from->WebSocket->headers); - $from->WebSocket->version = $this->getVersion($headers); - $from->WebSocket->headers = $headers; + if (true !== $conn->WebSocket->established) { + if (null === ($response = $this->handshaker->onData($conn, $msg))) { + return; } - $response = $from->WebSocket->version->handshake($from->WebSocket->headers); - $from->WebSocket->handshake = true; - - if ('' !== ($agreedSubProtocols = $this->getSubProtocolString($from->WebSocket->headers->getTokenizedHeader('Sec-WebSocket-Protocol', ',')))) { + // This needs to be refactored later on, incorporated with routing + if ('' !== ($agreedSubProtocols = $this->getSubProtocolString($from->WebSocket->request->getTokenizedHeader('Sec-WebSocket-Protocol', ',')))) { $response->setHeader('Sec-WebSocket-Protocol', $agreedSubProtocols); } - $response->setHeader('X-Powered-By', \Ratchet\VERSION); - $header = (string)$response; + $from->send((string)$response); - $from->send($header); + if (101 != $response->getStatusCode()) { + return $from->close(); + } - $conn = new WsConnection($from); - $this->connections->attach($from, $conn); + $conn->WebSocket->established = true; return $this->_decorating->onOpen($conn); } + /* + if (null !== ($parsed = $this->messager->onData($from, $msg))) { + $this->_decorating->onMessage($from, $parsed); + } + /**/ + +/*************************************************************/ + if (!isset($from->WebSocket->message)) { $from->WebSocket->message = $from->WebSocket->version->newMessage(); } @@ -135,12 +157,12 @@ class WsServer implements MessageComponentInterface { * {@inheritdoc} */ public function onClose(ConnectionInterface $conn) { + $decor = $this->connections[$conn]; + $this->connections->detach($conn); + // WS::onOpen is not called when the socket connects, it's call when the handshake is done // The socket could close before WS calls onOpen, so we need to check if we've "opened" it for the developer yet - if ($this->connections->contains($conn)) { - $decor = $this->connections[$conn]; - $this->connections->detach($conn); - + if ($decor->WebSocket->established) { $this->_decorating->onClose($decor); } } @@ -149,62 +171,14 @@ class WsServer implements MessageComponentInterface { * {@inheritdoc} */ public function onError(ConnectionInterface $conn, \Exception $e) { - if ($this->connections->contains($conn)) { +var_dump($e); + if ($conn->WebSocket->established) { $this->_decorating->onError($this->connections[$conn], $e); } else { $conn->close(); } } - /** - * Detect the WebSocket protocol version a client is using based on the HTTP header request - * @param string HTTP handshake request - * @return Version\VersionInterface - * @throws UnderFlowException If we think the entire header message hasn't been buffered yet - * @throws InvalidArgumentException If we can't understand protocol version request - * @todo Verify the first line of the HTTP header as per page 16 of RFC 6455 - */ - protected function getVersion(RequestInterface $request) { - foreach ($this->_versions as $name => $instance) { - if (null !== $instance) { - if ($instance::isProtocol($request)) { - return $instance; - } - } else { - $ns = __NAMESPACE__ . "\\Version\\{$name}"; - if ($ns::isProtocol($request)) { - $this->_versions[$name] = new $ns; - return $this->_versions[$name]; - } - } - } - - throw new \InvalidArgumentException('Could not identify WebSocket protocol'); - } - - /** - * @param string - * @return bool - * @todo Abstract, some hard coding done for (stupid) Hixie protocol - */ - protected function isMessageComplete($message) { - static $crlf = "\r\n\r\n"; - - $headers = (boolean)strstr($message, $crlf); - if (!$headers) { - - return false; - } - - if (strstr($message, 'Sec-WebSocket-Key2')) { - if (8 !== strlen(substr($message, strpos($message, $crlf) + strlen($crlf)))) { - return false; - } - } - - return true; - } - /** * @param string * @return boolean @@ -240,27 +214,4 @@ class WsServer implements MessageComponentInterface { return substr($string, 0, -1); } - - /** - * Disable a version of the WebSocket protocol *cough*Hixie76*cough* - * @param string The name of the version to disable - * @throws InvalidArgumentException If the given version does not exist - */ - public function disableVersion($name) { - if (!array_key_exists($name, $this->_versions)) { - throw new \InvalidArgumentException("Version {$name} not found"); - } - - unset($this->_versions[$name]); - } - - /** - * Set the option to mask the payload upon sending to client - * If WebSocket is used as server, this should be false, client to true - * @param bool - * @todo User shouldn't have to know/set this, need to figure out how to do this automatically - */ - public function setMaskPayload($opt) { - $this->_mask_payload = (boolean)$opt; - } } \ No newline at end of file diff --git a/tests/Ratchet/Tests/WebSocket/HandshakeNegotiatorTest.php b/tests/Ratchet/Tests/WebSocket/HandshakeNegotiatorTest.php new file mode 100644 index 0000000..288e30a --- /dev/null +++ b/tests/Ratchet/Tests/WebSocket/HandshakeNegotiatorTest.php @@ -0,0 +1,103 @@ +parser = new HandshakeNegotiator(); + } + + public function headersProvider() { + return array( + array(false, "GET / HTTP/1.1\r\nHost: socketo.me\r\n") + , array(true, "GET / HTTP/1.1\r\nHost: socketo.me\r\n\r\n") + , array(false, "GET / HTTP/1.1\r\nHost: socketo.me\r\n\r\n1") + , array(false, "GET / HTTP/1.1\r\nHost: socketo.me\r\n\r\nHixie✖") + , array(true, "GET / HTTP/1.1\r\nHost: socketo.me\r\n\r\nHixie✖\r\n\r\n") + , array(false, "GET / HTTP/1.1\r\nHost: socketo.me\r\n\r\nHixie\r\n") + ); + } + + /** + * @dataProvider headersProvider + */ + public function testIsEom($expected, $message) { + $this->assertEquals($expected, $this->parser->isEom($message)); + } + + public function testFluentInterface() { + $rfc = new RFC6455; + + $this->assertSame($this->parser, $this->parser->disableVersion(13)); + $this->assertSame($this->parser, $this->parser->enableVersion($rfc)); + } + + public function testGetVersion() { + $this->parser->disableVersion(13); + $rfc = new RFC6455; + $this->parser->enableVersion($rfc); + + $req = new EntityEnclosingRequest('get', '/', array( + 'Host' => 'socketo.me' + , 'Sec-WebSocket-Version' => 13 + )); + + $this->assertSame($rfc, $this->parser->getVersion($req)); + } + + public function testGetNopeVersionAndDisable() { + $this->parser->disableVersion(13); + + $req = new EntityEnclosingRequest('get', '/', array( + 'Host' => 'socketo.me' + , 'Sec-WebSocket-Version' => 13 + )); + + $this->assertNull($this->parser->getVersion($req)); + } + + public function testGetSupportedVersionString() { + $v1 = new RFC6455; + $v2 = new HyBi10; + + $parser = new HandshakeNegotiator(); + $parser->enableVersion($v1); + $parser->enableVersion($v2); + + $string = $parser->getSupportedVersionString(); + $values = explode(',', $string); + + $this->assertContains($v1->getVersionNumber(), $values); + $this->assertContains($v2->getVersionNumber(), $values); + } + + public function testGetSupportedVersionAfterRemoval() { + $this->parser->disableVersion(0); + + $values = explode(',', $this->parser->getSupportedVersionString()); + + $this->assertEquals(2, count($values)); + $this->assertFalse(array_search(0, $values)); + } + + public function testBufferOverflowResponse() { + $conn = new WsConnection(new ConnectionStub); + $this->parser->onOpen($conn); + + $this->parser->maxSize = 20; + + $this->assertNull($this->parser->onData($conn, "GET / HTTP/1.1\r\n")); + $this->assertGreaterThan(400, $this->parser->onData($conn, "Header-Is: Too Big")->getStatusCode()); + } +} \ No newline at end of file