RFC handshake verification + unit tests

Went through section 4 of RFC 6455 making sure incoming handshake was valid with accompanying unit tests
This commit is contained in:
Chris Boden 2011-12-02 23:58:08 -05:00
parent d85ea57ece
commit 788b1f66cc
8 changed files with 464 additions and 3 deletions

View File

@ -7,7 +7,6 @@ use Ratchet\Resource\Command\Factory;
use Ratchet\Resource\Command\CommandInterface; use Ratchet\Resource\Command\CommandInterface;
use Ratchet\Resource\Command\Action\SendMessage; use Ratchet\Resource\Command\Action\SendMessage;
use Ratchet\Application\WebSocket\Util\HTTP; use Ratchet\Application\WebSocket\Util\HTTP;
use Ratchet\Application\WebSocket\Version;
/** /**
* The adapter to handle WebSocket requests/responses * The adapter to handle WebSocket requests/responses
@ -37,6 +36,7 @@ class App implements ApplicationInterface, ConfiguratorInterface {
protected $_versions = array( protected $_versions = array(
'HyBi10' => null 'HyBi10' => null
, 'Hixie76' => null , 'Hixie76' => null
, 'RFC6455' => null
); );
protected $_mask_payload = false; protected $_mask_payload = false;
@ -75,6 +75,7 @@ class App implements ApplicationInterface, ConfiguratorInterface {
/** /**
* Do handshake, frame/unframe messages coming/going in stack * Do handshake, frame/unframe messages coming/going in stack
* @todo This needs some major refactoring * @todo This needs some major refactoring
* @todo "Once the client's opening handshake has been sent, the client MUST wait for a response from the server before sending any further data."
*/ */
public function onMessage(Connection $from, $msg) { public function onMessage(Connection $from, $msg) {
if (true !== $from->WebSocket->handshake) { if (true !== $from->WebSocket->handshake) {
@ -209,6 +210,7 @@ class App implements ApplicationInterface, ConfiguratorInterface {
* @return Version\VersionInterface * @return Version\VersionInterface
* @throws UnderFlowException If we think the entire header message hasn't been buffered yet * @throws UnderFlowException If we think the entire header message hasn't been buffered yet
* @throws InvalidArgumentException If we can't understand protocol version request * @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($message) { protected function getVersion($message) {
if (false === strstr($message, "\r\n\r\n")) { // This CAN fail with Hixie, depending on the TCP buffer in between if (false === strstr($message, "\r\n\r\n")) { // This CAN fail with Hixie, depending on the TCP buffer in between

View File

@ -4,6 +4,7 @@ namespace Ratchet\Application\WebSocket\Util;
/** /**
* A helper class for handling HTTP requests * A helper class for handling HTTP requests
* @todo Needs re-write...http_parse_headers is a PECL extension that changes the case to unexpected values * @todo Needs re-write...http_parse_headers is a PECL extension that changes the case to unexpected values
* @todo Again, RE-WRITE - I want all the expected headers to at least be set in the returned, even if not there, set as null - having to do too much work in HandshaekVerifier
*/ */
class HTTP { class HTTP {
/** /**
@ -12,7 +13,18 @@ class HTTP {
* @return array * @return array
*/ */
public static function getHeaders($http_message) { public static function getHeaders($http_message) {
return function_exists('http_parse_headers') ? http_parse_headers($http_message) : self::http_parse_headers($http_message); $header_array = function_exists('http_parse_headers') ? http_parse_headers($http_message) : self::http_parse_headers($http_message);
return $header_array + array(
'Host' => null
, 'Upgrade' => null
, 'Connection' => null
, 'Sec-Websocket-Key' => null
, 'Origin' => null
, 'Sec-Websocket-Protocol' => null
, 'Sec-Websocket-Version' => null
, 'Sec-Websocket-Origin' => null
);
} }
/** /**

View File

@ -1,6 +1,9 @@
<?php <?php
namespace Ratchet\Application\WebSocket\Version; namespace Ratchet\Application\WebSocket\Version;
/**
* @todo Note: Even though this is the "legacy" HyBi version, it's using the RFC Message and Frame classes - change if needed
*/
class HyBi10 extends RFC6455 { class HyBi10 extends RFC6455 {
public static function isProtocol(array $headers) { public static function isProtocol(array $headers) {
if (isset($headers['Sec-Websocket-Version'])) { if (isset($headers['Sec-Websocket-Version'])) {

View File

@ -1,5 +1,6 @@
<?php <?php
namespace Ratchet\Application\WebSocket\Version; namespace Ratchet\Application\WebSocket\Version;
use Ratchet\Application\WebSocket\Version\RFC6455\HandshakeVerifier;
use Ratchet\Application\WebSocket\Util\HTTP; use Ratchet\Application\WebSocket\Util\HTTP;
/** /**
@ -9,8 +10,14 @@ class RFC6455 implements VersionInterface {
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
/** /**
* @todo When I support later version (that implement extension) change >= 6 to 6 through 10 (or w/e #) * @var RFC6455\HandshakeVerifier
*/ */
protected $_verifier;
public function __construct() {
$this->_verifier = new HandshakeVerifier;
}
public static function isProtocol(array $headers) { public static function isProtocol(array $headers) {
if (isset($headers['Sec-Websocket-Version'])) { if (isset($headers['Sec-Websocket-Version'])) {
if ((int)$headers['Sec-Websocket-Version'] == 13) { if ((int)$headers['Sec-Websocket-Version'] == 13) {
@ -24,11 +31,16 @@ class RFC6455 implements VersionInterface {
/** /**
* @return array * @return array
* I kept this as an array and combined in App for future considerations...easier to add a subprotol as a key value than edit a string * I kept this as an array and combined in App for future considerations...easier to add a subprotol as a key value than edit a string
* @todo Decide what to do on failure...currently throwing an exception and I think socket connection is closed. Should be sending 40x error - but from where?
*/ */
public function handshake($message) { public function handshake($message) {
$headers = HTTP::getHeaders($message); $headers = HTTP::getHeaders($message);
$key = $this->sign($headers['Sec-Websocket-Key']); $key = $this->sign($headers['Sec-Websocket-Key']);
if (true !== $this->_verifier->verifyAll($headers)) {
throw new \InvalidArgumentException('Invalid HTTP header');
}
return array( return array(
'' => 'HTTP/1.1 101 Switching Protocols' '' => 'HTTP/1.1 101 Switching Protocols'
, 'Upgrade' => 'websocket' , 'Upgrade' => 'websocket'

View File

@ -0,0 +1,138 @@
<?php
namespace Ratchet\Application\WebSocket\Version\RFC6455;
/**
* These are checks to ensure the client requested handshake are valid
* Verification rules come from section 4.2.1 of the RFC6455 document
* @todo Currently just returning invalid - should consider returning appropriate HTTP status code error #s
*/
class HandshakeVerifier {
/**
* Given an array of the headers this method will run through all verification methods
* @param array
* @return bool TRUE if all headers are valid, FALSE if 1 or more were invalid
*/
public function verifyAll(array $headers) {
$passes = 0;
$passes += (int)$this->verifyMethod($headers['Request Method']);
//$passes += (int)$this->verifyHTTPVersion($headers['???']); // This isn't in the array!
$passes += (int)$this->verifyRequestURI($headers['Request Url']);
$passes += (int)$this->verifyHost($headers['Host']);
$passes += (int)$this->verifyUpgradeRequest($headers['Upgrade']);
$passes += (int)$this->verifyConnection($headers['Connection']);
$passes += (int)$this->verifyKey($headers['Sec-Websocket-Key']);
//$passes += (int)$this->verifyVersion($headers['Sec-Websocket-Version']); // Temporarily breaking functionality
return (6 === $passes);
}
/**
* Test the HTTP method. MUST be "GET"
* @param string
* @return bool
* @todo Look into STD if "get" is valid (am I supposed to do case conversion?)
*/
public function verifyMethod($val) {
return ('GET' === $val);
}
/**
* Test the HTTP version passed. MUST be 1.1 or greater
* @param string|int
* @return bool
*/
public function verifyHTTPVersion($val) {
return (1.1 <= (double)$val);
}
/**
* @param string
* @return bool
* @todo Implement this functionality
*/
public function verifyRequestURI($val) {
return true;
}
/**
* @param string|null
* @return bool
* @todo Find out if I can find the master socket, ensure the port is attached to header if not 80 or 443 - not sure if this is possible, as I tried to hide it
* @todo Once I fix HTTP::getHeaders just verify this isn't NULL or empty...or manybe need to verify it's a valid domin??? Or should it equal $_SERVER['HOST'] ?
*/
public function verifyHost($val) {
return (null !== $val);
}
/**
* Verify the Upgrade request to WebSockets.
* @param string MUST equal "websocket"
* @return bool
*/
public function verifyUpgradeRequest($val) {
return ('websocket' === $val);
}
/**
* Verify the Connection header
* @param string MUST equal "Upgrade"
* @return bool
*/
public function verifyConnection($val) {
if ('Upgrade' === $val) {
return true;
}
$vals = explode(',', str_replace(', ', ',', $val));
return (false !== array_search('Upgrade', $vals));
}
/**
* This function verifyies the nonce is valid (64 big encoded, 16 bytes random string)
* @param string|null
* @return bool
* @todo The spec says we don't need to base64_decode - can I just check if the length is 24 and not decode?
*/
public function verifyKey($val) {
return (16 === strlen(base64_decode((string)$val)));
}
/**
* Verify Origin matches RFC6454 IF it is set
* Origin is an optional field
* @param string|null
* @return bool
* @todo Implement verification functality - see section 4.2.1.7
*/
public function verifyOrigin($val) {
if (null === $val) {
return true;
}
// logic here
return true;
}
/**
* Verify the version passed matches this RFC
* @param string|int MUST equal 13|"13"
* @return bool
* @todo Ran in to a problem here...I'm having HyBi use the RFC files, this breaks it! oops
*/
public function verifyVersion($val) {
return (13 === (int)$val);
}
/**
* @todo Write logic for this method. See section 4.2.1.8
*/
public function verifyProtocol($val) {
}
/**
* @todo Write logic for this method. See section 4.2.1.9
*/
public function verifyExtensions($val) {
}
}

View File

@ -20,6 +20,8 @@ interface VersionInterface {
* Perform the handshake and return the response headers * Perform the handshake and return the response headers
* @param string * @param string
* @return array|string * @return array|string
* @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)
*/ */
function handshake($message); function handshake($message);

View File

@ -0,0 +1,166 @@
<?php
namespace Ratchet\Tests\Application\WebSocket\Version\RFC6455;
use Ratchet\Application\WebSocket\Version\RFC6455\HandshakeVerifier;
/**
* @covers Ratchet\Application\WebSocket\Version\RFC6455\HandshakeVerifier
*/
class HandshakeVerifierTest extends \PHPUnit_Framework_TestCase {
/**
* @var Ratchet\Application\WebSocket\Version\RFC6455\HandshakeVerifier
*/
protected $_v;
public function setUp() {
$this->_v = new HandshakeVerifier;
}
public static function methodProvider() {
return array(
array(true, 'GET')
, array(false, 'get') // I'm not sure if this is valid or not, need to check standard
, array(false, 'POST')
, array(false, 'DELETE')
, array(false, 'PUT')
, array(false, 'PATCH')
);
}
/**
* @dataProvider methodProvider
*/
public function testMethodMustBeGet($result, $in) {
$this->assertEquals($result, $this->_v->verifyMethod($in));
}
public static function httpVersionProvider() {
return array(
array(true, 1.1)
, array(true, '1.1')
, array(true, 1.2)
, array(true, '1.2')
, array(true, 2)
, array(true, '2')
, array(true, '2.0')
, array(false, '1.0')
, array(false, 1)
, array(false, '0.9')
, array(false, '')
, array(false, 'hello')
);
}
/**
* @dataProvider httpVersionProvider
*/
public function testHttpVersionIsAtLeast1Point1($expected, $in) {
$this->assertEquals($expected, $this->_v->verifyHTTPVersion($in));
}
/**
* @todo Add failing values in here
*/
public static function uRIProvider() {
return array(
array(true, '/chat')
);
}
/**
* @dataProvider URIProvider
*/
public function testRequestUri($expected, $in) {
return $this->markTestIncomplete('Method this test is testing is incomplete');
$this->assertEquals($expected, $this->_v->verifyRequestURI($in));
}
public static function hostProvider() {
return array(
array(true, 'server.example.com')
, array(false, null)
);
}
/**
* @dataProvider HostProvider
*/
public function testVerifyHostIsSet($expected, $in) {
$this->assertEquals($expected, $this->_v->verifyHost($in));
}
public static function upgradeProvider() {
return array(
array(true, 'websocket')
, array(false, 'Websocket')
, array(false, null)
, array(false, '')
);
}
/**
* @dataProvider upgradeProvider
*/
public function testVerifyUpgradeIsWebSocket($expected, $val) {
$this->assertEquals($expected, $this->_v->verifyUpgradeRequest($val));
}
public static function connectionProvider() {
return array(
array(true, 'Upgrade')
, array(false, 'upgrade')
, array(true, 'keep-alive, Upgrade')
, array(true, 'Upgrade, keep-alive')
, array(true, 'keep-alive, Upgrade, something')
, array(false, '')
, array(false, null)
);
}
/**
* @dataProvider connectionProvider
*/
public function testConnectionHeaderVerification($expected, $val) {
$this->assertEquals($expected, $this->_v->verifyConnection($val));
}
public static function keyProvider() {
return array(
array(true, 'hkfa1L7uwN6DCo4IS3iWAw==')
, array(true, '765vVoQpKSGJwPzJIMM2GA==')
, array(true, 'AQIDBAUGBwgJCgsMDQ4PEC==')
, array(true, 'axa2B/Yz2CdpfQAY2Q5P7w==')
, array(false, 0)
, array(false, 'Hello World')
, array(false, '1234567890123456')
, array(false, '123456789012345678901234')
);
}
/**
* @dataProvider keyProvider
*/
public function testKeyIsBase64Encoded16BitNonce($expected, $val) {
$this->assertEquals($expected, $this->_v->verifyKey($val));
}
public static function versionProvider() {
return array(
array(true, 13)
, array(true, '13')
, array(false, 12)
, array(false, 14)
, array(false, '14')
, array(false, 'hi')
, array(false, '')
, array(false, null)
);
}
/**
* @dataProvider versionProvider
*/
public function testVersionEquals13($expected, $in) {
$this->assertEquals($expected, $this->_v->verifyVersion($in));
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace Ratchet\Tests\Application\WebSocket\Version;
use Ratchet\Application\WebSocket\Version\RFC6455;
use Ratchet\Application\WebSocket\Version\RFC6455\Frame;
/**
* @covers Ratchet\Application\WebSocket\Version\RFC6455
*/
class RFC6455Test extends \PHPUnit_Framework_TestCase {
protected $_version;
public function setUp() {
$this->_version = new RFC6455();
}
/**
* Is this useful?
*/
public function testClassImplementsVersionInterface() {
$constraint = $this->isInstanceOf('\\Ratchet\\Application\\WebSocket\\Version\\VersionInterface');
$this->assertThat($this->_version, $constraint);
}
/**
* @dataProvider HandshakeProvider
*/
public function testKeySigningForHandshake($key, $accept) {
$this->assertEquals($accept, $this->_version->sign($key));
}
public static function HandshakeProvider() {
return array(
array('x3JJHMbDL1EzLkh9GBhXDw==', 'HSmrc0sMlYUkAGmm5OPpG2HaGWk=')
, array('dGhlIHNhbXBsZSBub25jZQ==', 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=')
);
}
/**
* @dataProvider UnframeMessageProvider
*/
public function testUnframeMessage($message, $framed) {
$frame = new Frame;
$frame->addBuffer(base64_decode($framed));
$this->assertEquals($message, $frame->getPayload());
}
public static function UnframeMessageProvider() {
return array(
array('Hello World!', 'gYydAIfa1WXrtvIg0LXvbOP7')
, array('!@#$%^&*()-=_+[]{}\|/.,<>`~', 'gZv+h96r38f9j9vZ+IHWrvOWoayF9oX6gtfRqfKXwOeg')
, array('ಠ_ಠ', 'gYfnSpu5B/g75gf4Ow==')
, array("The quick brown fox jumps over the lazy dog. All work and no play makes Chris a dull boy. I'm trying to get past 128 characters for a unit test here...", 'gf4Amahb14P8M7Kj2S6+4MN7tfHHLLmjzjSvo8IuuvPbe7j1zSn398A+9+/JIa6jzDSwrYh7lu/Ee6Ds2jD34sY/9+3He6fvySL37skwsvCIGL/xwSj34og/ou/Ee7Xs0XX3o+F8uqPcKa7qxjz398d7sObce6fi2y/3sppj9+DAOqXiyy+y8dt7sezae7aj3TW+94gvsvDce7/m2j75rYY=')
);
}
public function testUnframeMatchesPreFraming() {
$string = 'Hello World!';
$framed = $this->_version->frame($string);
$frame = new Frame;
$frame->addBuffer($framed);
$this->assertEquals($string, $frame->getPayload());
}
public static $good_rest = 'GET /chat HTTP/1.1';
public static $good_header = array(
'Host' => 'server.example.com'
, 'Upgrade' => 'websocket'
, 'Connection' => 'Upgrade'
, 'Sec-WebSocket-Key' => 'dGhlIHNhbXBsZSBub25jZQ=='
, 'Origin' => 'http://example.com'
, 'Sec-WebSocket-Protocol' => 'chat, superchat'
, 'Sec-WebSocket-Version' => 13
);
/**
* A helper function to try and quickly put together a valid WebSocket HTTP handshake
* but optionally replace a piece to an invalid value for failure testing
*/
public static function getAndSpliceHeader($key = null, $val = null) {
$headers = static::$good_header;
if (null !== $key && null !== $val) {
$headers[$key] = $val;
}
$header = '';
foreach ($headers as $key => $val) {
if (!empty($key)) {
$header .= "{$key}: ";
}
$header .= "{$val}\r\n";
}
$header .= "\r\n";
return $header;
}
public static function headerHandshakeProvider() {
return array(
array(false, "GET /test HTTP/1.0\r\n" . static::getAndSpliceHeader())
);
}
/* RFC example of a good header
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
*/
/**
* @dataProvider headerHandshakeProvider
*/
public function testVariousHeadersToCheckHandshakeTolerance($pass, $header) {
$this->markTestIncomplete();
}
}