Message buffering & Refactoring

Refactored unframe() methods into Message/Frame classes (per protocol version)
Change onRecv of WebSocket App to use new interfaces to test statuses, resulting in reuniting a message fragmented by TCP
Wrote unit test covering most of new HyBi10 Frame class
This commit is contained in:
Chris Boden 2011-11-18 16:37:32 -05:00
parent 1355e4400b
commit 5653f01f2f
11 changed files with 721 additions and 13 deletions

View File

@ -101,14 +101,35 @@ class App implements ApplicationInterface, ConfiguratorInterface {
return $comp;
}
// buffer!
$msg = $from->WebSocket->version->unframe($msg);
if (is_array($msg)) { // temporary
$msg = $msg['payload'];
if (!isset($from->WebSocket->message)) {
$from->WebSocket->message = $from->WebSocket->version->newMessage();
}
return $this->prepareCommand($this->_app->onRecv($from, $msg));
// There is a frame fragment attatched to the connection, add to it
if (!isset($from->WebSocket->frame)) {
$from->WebSocket->frame = $from->WebSocket->version->newFrame();
}
$from->WebSocket->frame->addBuffer($msg);
if ($from->WebSocket->frame->isCoalesced()) {
if ($from->WebSocket->frame->getOpcode() > 2) {
throw new \UnexpectedValueException('Control frame support coming soon!');
}
// Check frame
// If is control frame, do your thing
// Else, add to message
// Control frames (ping, pong, close) can be sent in between a fragmented message
$from->WebSocket->message->addFrame($from->WebSocket->frame);
unset($from->WebSocket->frame);
}
if ($from->WebSocket->message->isCoalesced()) {
$cmds = $this->prepareCommand($this->_app->onRecv($from, (string)$from->WebSocket->message));
unset($from->WebSocket->message);
return $cmds;
}
}
public function onClose(Connection $conn) {
@ -153,7 +174,7 @@ class App implements ApplicationInterface, ConfiguratorInterface {
* @todo Can/will add more versions later, but perhaps a chain of responsibility, ask each version if they want to handle the request
*/
protected function getVersion($message) {
if (false === strstr($message, "\r\n\r\n")) { // This _could_ fail with Hixie
if (false === strstr($message, "\r\n\r\n")) { // This CAN fail with Hixie, depending on the TCP buffer in between
throw new \UnderflowException;
}

View File

@ -1,7 +1,18 @@
<?php
namespace Ratchet\Protocol\WebSocket;
namespace Ratchet\Application\WebSocket\Version;
interface FrameInterface {
/**
* @alias getPayload
*/
function __toString();
/**
* Dunno if I'll use this
* Thinking could be used if a control frame?
*/
// function __invoke();
/**
* @return bool
*/
@ -9,18 +20,19 @@ interface FrameInterface {
/**
* @param string
* @todo Theoretically, there won't be a buffer overflow (end of frame + start of new frame) - but test later, return a string with overflow here
*/
function addBuffer($buf);
/**
* @return bool
*/
function isFragment();
// function isFragment();
/**
* @return bool
*/
function isFinial();
function isFinal();
/**
* @return bool
@ -40,7 +52,7 @@ interface FrameInterface {
/**
* @return int
*/
function getReceivedPayloadLength();
// function getReceivedPayloadLength();
/**
* 32-big string

View File

@ -2,6 +2,13 @@
namespace Ratchet\Application\WebSocket\Version;
/**
* FOR THE LOVE OF BEER, PLEASE PLEASE PLEASE DON'T allow the use of this in your application!
* Hixie76 is bad for 2 (there's more) reasons:
* 1) The handshake is done in HTTP, which includes a key for signing in the body...
* BUT there is no Length defined in the header (as per HTTP spec) so the TCP buffer can't tell when the message is done!
* 2) By nature it's insecure. Google did a test study where they were able to do a
* man-in-the-middle attack on 10%-15% of the people who saw their add 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
@ -34,6 +41,14 @@ class Hixie76 implements VersionInterface {
;
}
public function newMessage() {
return new Hixie76\Message;
}
public function newFrame() {
return new Hixie76\Frame;
}
public function unframe($message) {
return substr($message, 1, strlen($message) - 2);
}

View File

@ -0,0 +1,58 @@
<?php
namespace Ratchet\Application\WebSocket\Version\Hixie76;
use Ratchet\Application\WebSocket\Version\FrameInterface;
/**
* This does not entirely follow the protocol to spec, but (mostly) works
* Hixie76 probably should not even be supported
*/
class Frame implements FrameInterface {
/**
* @type string
*/
protected $_data = '';
public function __toString() {
return $this->getPayload();
}
public function isCoalesced() {
return (boolean)($this->_data[0] == chr(0) && substr($this->_data, -1) == chr(255));
}
public function addBuffer($buf) {
$this->_data .= (string)$buf;
}
public function isFinal() {
return true;
}
public function isMasked() {
return false;
}
public function getOpcode() {
return 1;
}
public function getPayloadLength() {
if (!$this->isCoalesced()) {
throw new \UnderflowException('Not enough of the message has been buffered to determine the length of the payload');
}
return strlen($this->_data) - 2;
}
public function getMaskingKey() {
return '';
}
public function getPayload() {
if (!$this->isCoalesced()) {
return new \UnderflowException('Not enough data buffered to read payload');
}
return substr($this->_data, 1, strlen($this->_data) - 2);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Ratchet\Application\WebSocket\Version\Hixie76;
use Ratchet\Application\WebSocket\Version\MessageInterface;
use Ratchet\Application\WebSocket\Version\FrameInterface;
class Message implements MessageInterface {
/**
* @var Ratchet\Application\WebSocket\Version\FrameInterface
*/
protected $_frame = null;
public function __toString() {
return $this->getPayload();
}
public function isCoalesced() {
if (!($this->_frame instanceof FrameInterface)) {
return false;
}
return $this->_frame->isCoalesced();
}
public function addFrame(FrameInterface $fragment) {
if (null !== $this->_frame) {
throw new \OverflowException('Hixie76 does not support multiple framing of messages');
}
$this->_frame = $fragment;
}
public function getOpcode() {
// Hixie76 only supported text messages
return 1;
}
public function getPayloadLength() {
throw new \DomainException('Please sir, may I have some code? (' . __FUNCTION__ . ')');
}
public function getPayload() {
if (!$this->isCoalesced()) {
throw new \UnderflowException('Message has not been fully buffered yet');
}
return $this->_frame->getPayload();
}
}

View File

@ -5,12 +5,14 @@ use Ratchet\Application\WebSocket\Util\HTTP;
/**
* The HyBi-10 version, identified in the headers as version 8, is currently implemented by the latest Chrome and Firefix version
* @link http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-10
* @todo Naming...I'm not fond of this naming convention...
*/
class HyBi10 implements VersionInterface {
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
/**
* @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
*/
public function handshake($message) {
$headers = HTTP::getHeaders($message);
@ -25,6 +27,20 @@ class HyBi10 implements VersionInterface {
);
}
/**
* @return HyBi10\Message
*/
public function newMessage() {
return new HyBi10\Message;
}
/**
* @return HyBi10\Frame
*/
public function newFrame() {
return new HyBi10\Frame;
}
/**
* Unframe a message received from the client
* Thanks to @lemmingzshadow for the code on decoding a HyBi-10 frame

View File

@ -0,0 +1,203 @@
<?php
namespace Ratchet\Application\WebSocket\Version\HyBi10;
use Ratchet\Application\WebSocket\Version\FrameInterface;
class Frame implements FrameInterface {
/**
* The contents of the frame
* @var string
*/
protected $_data = '';
/**
* Number of bytes received from the frame
* @var int
*/
public $_bytes_rec = 0;
/**
* Number of bytes in the payload (as per framing protocol)
* @var int
*/
protected $_pay_len_def = -1;
/**
* Bit 9-15
* @var int
*/
protected $_pay_check = -1;
public function __toString() {
return (string)$this->getPayload();
}
public function isCoalesced() {
try {
$payload_length = $this->getPayloadLength();
$payload_start = $this->getPayloadStartingByte();
} catch (\UnderflowException $e) {
return false;
}
return $payload_length + $payload_start === $this->_bytes_rec;
}
public function addBuffer($buf) {
$buf = (string)$buf;
$this->_data .= $buf;
$this->_bytes_rec += strlen($buf);
}
public function isFinal() {
if ($this->_bytes_rec < 1) {
throw new \UnderflowException('Not enough bytes received to determine if this is the final frame in message');
}
$fbb = sprintf('%08b', ord($this->_data[0]));
return (boolean)(int)$fbb[0];
}
public function isMasked() {
if ($this->_bytes_rec < 2) {
throw new \UnderflowException("Not enough bytes received ({$this->_bytes_rec}) to determine if mask is set");
}
return (boolean)bindec(substr(sprintf('%08b', ord($this->_data[1])), 0, 1));
}
public function getOpcode() {
if ($this->_bytes_rec < 1) {
throw new \UnderflowException('Not enough bytes received to determine opcode');
}
return bindec(substr(sprintf('%08b', ord($this->_data[0])), 4, 4));
}
/**
* Gets the decimal value of bits 9 (10th) through 15 inclusive
* @return int
* @throws UnderflowException If the buffer doesn't have enough data to determine this
*/
protected function getFirstPayloadVal() {
if ($this->_bytes_rec < 2) {
throw new \UnderflowException('Not enough bytes received');
}
return ord($this->_data[1]) & 127;
}
/**
* @return int (7|23|71) Number of bits defined for the payload length in the fame
* @throws UnderflowException
*/
protected function getNumPayloadBits() {
if ($this->_bytes_rec < 2) {
throw new \UnderflowException('Not enough bytes received');
}
// By default 7 bits are used to describe the payload length
// These are bits 9 (10th) through 15 inclusive
$bits = 7;
// Get the value of those bits
$check = $this->getFirstPayloadVal();
// If the value is 126 the 7 bits plus the next 16 are used to describe the payload length
if ($check >= 126) {
$bits += 16;
}
// If the value of the initial payload length are is 127 an additional 48 bits are used to describe length
// Note: The documentation specifies the length is to be 63 bits, but I think that's a type and is 64 (16+48)
if ($check === 127) {
$bits += 48;
}
if (!in_array($bits, array(7, 23, 71))) {
throw new \UnexpectedValueException("Malformed frame, invalid payload length provided");
}
return $bits;
}
/**
* This just returns the number of bytes used in the frame to describe the payload length (as opposed to # of bits)
* @see getNumPayloadBits
*/
protected function getNumPayloadBytes() {
return (1 + $this->getNumPayloadBits()) / 8;
}
public function getPayloadLength() {
if ($this->_pay_len_def !== -1) {
return $this->_pay_len_def;
}
$length_check = $this->getFirstPayloadVal();
if ($length_check <= 125) {
$this->_pay_len_def = $length_check;
return $this->getPayloadLength();
}
$byte_length = $this->getNumPayloadBytes();
if ($this->_bytes_rec < 1 + $byte_length) {
throw new \UnderflowException('Not enough data buffered to determine payload length');
}
$strings = array();
for ($i = 2; $i < $byte_length + 1; $i++) {
$strings[] = ord($this->_data[$i]);
}
$this->_pay_len_def = bindec(vsprintf(str_repeat('%08b', $byte_length - 1), $strings));
return $this->getPayloadLength();
}
public function getMaskingKey() {
if (!$this->isMasked()) {
return '';
}
$length = 4;
$start = 1 + $this->getNumPayloadBytes();
if ($this->_bytes_rec < $start + $length) {
throw new \UnderflowException('Not enough data buffered to calculate the masking key');
}
return substr($this->_data, $start, $length);
}
public function getPayloadStartingByte() {
return 1 + $this->getNumPayloadBytes() + strlen($this->getMaskingKey());
}
public function getPayload() {
if (!$this->isCoalesced()) {
throw new \UnderflowException('Can not return partial message');
}
$payload = '';
$length = $this->getPayloadLength();
if ($this->isMasked()) {
$mask = $this->getMaskingKey();
$start = $this->getPayloadStartingByte();
for ($i = 0; $i < $length; $i++) {
$payload .= $this->_data[$i + $start] ^ $mask[$i % 4];
}
} else {
$payload = substr($this->_data, $start, $this->getPayloadLength());
}
if (strlen($payload) !== $length) {
// Is this possible? isCoalesced() math _should_ ensure if there is mal-formed data, it would return false
throw new \UnexpectedValueException('Payload length does not match expected length');
}
return $payload;
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace Ratchet\Application\WebSocket\Version\HyBi10;
use Ratchet\Application\WebSocket\Version\MessageInterface;
use Ratchet\Application\WebSocket\Version\FrameInterface;
class Message implements MessageInterface {
/**
* @var SplDoublyLinkedList
*/
protected $_frames;
public function __construct() {
$this->_frames = new \SplDoublyLinkedList;
}
public function __toString() {
return $this->getPayload();
}
public function isCoalesced() {
if (count($this->_frames) == 0) {
return false;
}
$last = $this->_frames->top();
return ($last->isCoalesced() && $last->isFinal());
}
/**
* @todo Should I allow addFrame if the frame is not coalesced yet? I believe I'm assuming this class will only receive fully formed frame messages
* @todo Also, I should perhaps check the type...control frames (ping/pong/close) are not to be considered part of a message
*/
public function addFrame(FrameInterface $fragment) {
$this->_frames->push($fragment);
}
public function getOpcode() {
if (count($this->_frames) == 0) {
throw new \UnderflowException('No frames have been added to this message');
}
return $this->_frames->bottom()->getOpcode();
}
public function getPayloadLength() {
throw new \DomainException('Please sir, may I have some code? (' . __FUNCTION__ . ')');
}
public function getPayload() {
if (!$this->isCoalesced()) {
throw new \UnderflowMessage('Message has not been put back together yet');
}
$buffer = '';
foreach ($this->_frames as $frame) {
$buffer .= (string)$frame;
}
return $buffer;
}
}

View File

@ -1,10 +1,15 @@
<?php
namespace Ratchet\Protocol\WebSocket;
namespace Ratchet\Application\WebSocket\Version;
/**
* @todo Consider making parent interface/composite for Message/Frame with (isCoalesced, getOpcdoe, getPayloadLength, getPayload)
*/
interface MessageInterface {
/**
* @alias getPayload
*/
function __toString();
/**
* @return bool
*/
@ -13,7 +18,7 @@ interface MessageInterface {
/**
* @param FragmentInterface
*/
function addFragment(FragmentInterface $fragment);
function addFrame(FrameInterface $fragment);
/**
* @return int

View File

@ -13,11 +13,22 @@ interface VersionInterface {
*/
function handshake($message);
/**
* @return MessageInterface
*/
function newMessage();
/**
* @return FrameInterface
*/
function newFrame();
/**
* Get a framed message as per the protocol and return the decoded message
* @param string
* @return string
* @todo Return a frame object with message, type, masked?
* @deprecated
*/
function unframe($message);

View File

@ -0,0 +1,256 @@
<?php
namespace Ratchet\Tests\Application\WebSocket\Version\HyBi10;
use Ratchet\Application\WebSocket\Version\HyBi10\Frame;
/**
* @covers Ratchet\Application\WebSocket\Version\HyBi10\Frame
* @todo getMaskingKey, getPayloadStartingByte don't have tests yet
* @todo Could use some clean up in general, I had to rush to fix a bug for a deadline, sorry.
*/
class FrameTest extends \PHPUnit_Framework_TestCase {
protected $_firstByteFinText = '10000001';
protected $_secondByteMaskedSPL = '11111101';
protected $_frame;
protected $_packer;
public function setUp() {
$this->_frame = new Frame;
}
protected static function convert($in) {
return pack('C', bindec($in));
}
/**
* This is a data provider
* @param string The UTF8 message
* @param string The WebSocket framed message, then base64_encoded
*/
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=')
);
}
/**
* A data provider for testing the first byte of a WebSocket frame
* @param bool Given, is the byte indicate this is the final frame
* @param int Given, what is the expected opcode
* @param string of 0|1 Each character represents a bit in the byte
*/
public static function firstByteProvider() {
return array(
array(false, 8, '00001000')
, array(true, 10, '10001010')
, array(false, 15, '00001111')
, array(true, 1, '10000001')
, array(true, 15, '11111111')
);
}
public function testUnderflowExceptionFromAllTheMethodsMimickingBuffering() {
return $this->markTestIncomplete();
$this->expectException('\UnderflowException');
}
/**
* @dataProvider firstByteProvider
*/
public function testFinCodeFromBits($fin, $opcode, $bin) {
$this->_frame->addBuffer(static::convert($bin));
$this->assertEquals($fin, $this->_frame->isFinal());
}
/**
* @dataProvider UnframeMessageProvider
*/
public function testFinCodeFromFullMessage($msg, $encoded) {
$this->_frame->addBuffer(base64_decode($encoded));
$this->assertTrue($this->_frame->isFinal());
}
/**
* @dataProvider firstByteProvider
*/
public function testOpcodeFromBits($fin, $opcode, $bin) {
$this->_frame->addBuffer(static::convert($bin));
$this->assertEquals($opcode, $this->_frame->getOpcode());
}
/**
* @dataProvider UnframeMessageProvider
*/
public function testOpcodeFromFullMessage($msg, $encoded) {
$this->_frame->addBuffer(base64_decode($encoded));
$this->assertEquals(1, $this->_frame->getOpcode());
}
public static function payloadLengthDescriptionProvider() {
return array(
array(7, '01110101')
, array(7, '01111101')
, array(23, '01111110')
, array(71, '01111111')
, array(7, '00000000') // Should this throw an exception? Can a payload be empty?
, array(7, '00000001')
);
}
/**
* @dataProvider payloadLengthDescriptionProvider
*/
public function testFirstPayloadDesignationValue($bits, $bin) {
$this->_frame->addBuffer(static::convert($this->_firstByteFinText));
$this->_frame->addBuffer(static::convert($bin));
$ref = new \ReflectionClass($this->_frame);
$cb = $ref->getMethod('getFirstPayloadVal');
$cb->setAccessible(true);
$this->assertEquals(bindec($bin), $cb->invoke($this->_frame));
}
/**
* @dataProvider payloadLengthDescriptionProvider
*/
public function testDetermineHowManyBitsAreUsedToDescribePayload($expected_bits, $bin) {
$this->_frame->addBuffer(static::convert($this->_firstByteFinText));
$this->_frame->addBuffer(static::convert($bin));
$ref = new \ReflectionClass($this->_frame);
$cb = $ref->getMethod('getNumPayloadBits');
$cb->setAccessible(true);
$this->assertEquals($expected_bits, $cb->invoke($this->_frame));
}
public function secondByteProvider() {
return array(
array(true, 1, '10000001')
, array(false, 1, '00000001')
, array(true, 125, $this->_secondByteMaskedSPL)
);
}
/**
* @dataProvider secondByteProvider
*/
public function testIsMaskedReturnsExpectedValue($masked, $payload_length, $bin) {
$this->_frame->addBuffer(static::convert($this->_firstByteFinText));
$this->_frame->addBuffer(static::convert($bin));
$this->assertEquals($masked, $this->_frame->isMasked());
}
/**
* @dataProvider UnframeMessageProvider
*/
public function testIsMaskedFromFullMessage($msg, $encoded) {
$this->_frame->addBuffer(base64_decode($encoded));
$this->assertTrue($this->_frame->isMasked());
}
/**
* @dataProvider secondByteProvider
*/
public function testGetPayloadLengthWhenOnlyFirstFrameIsUsed($masked, $payload_length, $bin) {
$this->_frame->addBuffer(static::convert($this->_firstByteFinText));
$this->_frame->addBuffer(static::convert($bin));
$this->assertEquals($payload_length, $this->_frame->getPayloadLength());
}
/**
* @dataProvider UnframeMessageProvider
* @todo Not yet testing when second additional payload length descriptor
*/
public function testGetPayloadLengthFromFullMessage($msg, $encoded) {
$this->_frame->addBuffer(base64_decode($encoded));
$this->assertEquals(strlen($msg), $this->_frame->getPayloadLength());
}
protected function generateMask() {
}
protected function getPacker() {
return function($str) {
$packed = '';
for ($i = 0, $len = strlen($str); $i < $len; $i++) {
$packed .= pack('C', ord($str[$i]));
// $packed .= pack("c", ord(substr($str, $i, 1)));
}
return $packed;
};
}
public function maskingKeyProvider() {
$packer = $this->getPacker();
$mask_generator = function() use ($packer) {
$characters = 'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*() -=_+`~[]{}\|/,.<>';
$mask = '';
for ($i = 0; $i < 32; $i++) {
$mask .= $characters[mt_rand(0, strlen($characters) -1)];
}
return array($mask, $packer($mask));
};
return array(
$mask_generator()
, $mask_generator()
, $mask_generator()
);
}
/**
* @dataProvider maskingKeyProvider
* @todo I I wrote the dataProvider incorrectly, skpping for now
*/
public function testGetMaskingKey($mask, $bin) {
return $this->markTestIncomplete("I'm not packing the data properly in the provider, I think");
$this->_frame->addBuffer(static::convert($this->_firstByteFinText));
$this->_frame->addBuffer(static::convert($this->_secondByteMaskedSPL));
$this->_frame->addBuffer($bin);
$this->assertEquals($mask, $this->_frame->getMaskingKey());
}
/**
* @dataProvider UnframeMessageProvider
* @todo Move this test to bottom as it requires all methods of the class
*/
public function testUnframeFullMessage($unframed, $base_framed) {
$this->_frame->addBuffer(base64_decode($base_framed));
$this->assertEquals($unframed, $this->_frame->getPayload());
}
public static function messageFragmentProvider() {
return array(
array(false, '', '', '', '', '')
);
}
/**
* @dataProvider messageFragmentProvider
*/
public function testCheckPiecingTogetherMessage($coalesced, $first_bin, $secnd_bin, $mask, $payload1, $payload2) {
return $this->markTestIncomplete('Ran out of time, had to attend to something else, come finish me!');
$this->_frame->addBuffer(static::convert($first_bin));
$this->_frame->addBuffer(static::convert($second_bin));
// mask?
// $this->_frame->addBuffer(
// $this->_frame->addBuffer(
$this->assertEquals($coalesced, $this->_frame->isCoalesced());
}
}