diff --git a/src/Messaging/Message.php b/src/Messaging/Message.php index 4f3b014..33a6337 100644 --- a/src/Messaging/Message.php +++ b/src/Messaging/Message.php @@ -7,8 +7,14 @@ class Message implements \IteratorAggregate, MessageInterface { */ private $_frames; + /** + * @var int + */ + private $len; + public function __construct() { $this->_frames = new \SplDoublyLinkedList; + $this->len = 0; } public function getIterator() { @@ -39,6 +45,7 @@ class Message implements \IteratorAggregate, MessageInterface { * {@inheritdoc} */ public function addFrame(FrameInterface $fragment) { + $this->len += $fragment->getPayloadLength(); $this->_frames->push($fragment); return $this; @@ -59,17 +66,7 @@ class Message implements \IteratorAggregate, MessageInterface { * {@inheritdoc} */ public function getPayloadLength() { - $len = 0; - - foreach ($this->_frames as $frame) { - try { - $len += $frame->getPayloadLength(); - } catch (\UnderflowException $e) { - // Not an error, want the current amount buffered - } - } - - return $len; + return $this->len; } /** diff --git a/src/Messaging/MessageBuffer.php b/src/Messaging/MessageBuffer.php index 6b3b440..9b1a23b 100644 --- a/src/Messaging/MessageBuffer.php +++ b/src/Messaging/MessageBuffer.php @@ -42,12 +42,24 @@ class MessageBuffer { */ private $leftovers; + /** + * @var int + */ + private $maxMessagePayloadSize; + + /** + * @var int + */ + private $maxFramePayloadSize; + function __construct( CloseFrameChecker $frameChecker, callable $onMessage, callable $onControl = null, $expectMask = true, - $exceptionFactory = null + $exceptionFactory = null, + $maxMessagePayloadSize = null, // null for default - zero for no limit + $maxFramePayloadSize = null // null for default - zero for no limit ) { $this->closeFrameChecker = $frameChecker; $this->checkForMask = (bool)$expectMask; @@ -60,6 +72,25 @@ class MessageBuffer { $this->onControl = $onControl ?: function() {}; $this->leftovers = ''; + + $memory_limit_bytes = static::getMemoryLimit(); + + if ($maxMessagePayloadSize === null) { + $maxMessagePayloadSize = $memory_limit_bytes / 4; + } + if ($maxFramePayloadSize === null) { + $maxFramePayloadSize = $memory_limit_bytes / 4; + } + + if (!is_int($maxFramePayloadSize) || $maxFramePayloadSize > 0x7FFFFFFFFFFFFFFF || $maxFramePayloadSize < 0) { // this should be interesting on non-64 bit systems + throw new \InvalidArgumentException($maxFramePayloadSize . ' is not a valid maxFramePayloadSize'); + } + $this->maxFramePayloadSize = $maxFramePayloadSize; + + if (!is_int($maxMessagePayloadSize) || $maxMessagePayloadSize > 0x7FFFFFFFFFFFFFFF || $maxMessagePayloadSize < 0) { + throw new \InvalidArgumentException($maxMessagePayloadSize . 'is not a valid maxMessagePayloadSize'); + } + $this->maxMessagePayloadSize = $maxMessagePayloadSize; } public function onData($data) { @@ -68,6 +99,7 @@ class MessageBuffer { if ($dataLen < 2) { $this->leftovers = $data; + return; } @@ -90,6 +122,30 @@ class MessageBuffer { : unpack('J', $bytesToUpack)[1]; } + $closeFrame = null; + + if ($payload_length < 0) { + // this can happen when unpacking in php + $closeFrame = $this->newCloseFrame(Frame::CLOSE_PROTOCOL, 'Invalid frame length'); + } + + if (!$closeFrame && $this->maxFramePayloadSize > 1 && $payload_length > $this->maxFramePayloadSize) { + $closeFrame = $this->newCloseFrame(Frame::CLOSE_TOO_BIG, 'Maximum frame size exceeded'); + } + + if (!$closeFrame && $this->maxMessagePayloadSize > 0 + && $payload_length + ($this->messageBuffer ? $this->messageBuffer->getPayloadLength() : 0) > $this->maxMessagePayloadSize) { + $closeFrame = $this->newCloseFrame(Frame::CLOSE_TOO_BIG, 'Maximum message size exceeded'); + } + + if ($closeFrame !== null) { + $onControl = $this->onControl; + $onControl($closeFrame); + $this->leftovers = ''; + + return; + } + $isCoalesced = $dataLen - $frameStart >= $payload_length + $headerSize; if (!$isCoalesced) { break; @@ -264,4 +320,24 @@ class MessageBuffer { public function newCloseFrame($code, $reason = '') { return $this->newFrame(pack('n', $code) . $reason, true, Frame::OP_CLOSE); } -} \ No newline at end of file + + /** + * This is a separate function for testing purposes + * $memory_limit is only used for testing + * + * @param null|string $memory_limit + * @return int + */ + private static function getMemoryLimit($memory_limit = null) { + $memory_limit = $memory_limit === null ? \trim(\ini_get('memory_limit')) : $memory_limit; + $memory_limit_bytes = 0; + if ($memory_limit !== '') { + $shifty = ['k' => 0, 'm' => 10, 'g' => 20]; + $multiplier = strlen($memory_limit) > 1 ? substr(strtolower($memory_limit), -1) : ''; + $memory_limit = (int)$memory_limit; + $memory_limit_bytes = in_array($multiplier, array_keys($shifty), true) ? $memory_limit * 1024 << $shifty[$multiplier] : $memory_limit; + } + + return $memory_limit_bytes < 0 ? 0 : $memory_limit_bytes; + } +} diff --git a/tests/unit/Messaging/MessageBufferTest.php b/tests/unit/Messaging/MessageBufferTest.php index 567afa2..b5925ab 100644 --- a/tests/unit/Messaging/MessageBufferTest.php +++ b/tests/unit/Messaging/MessageBufferTest.php @@ -69,4 +69,299 @@ class MessageBufferTest extends \PHPUnit_Framework_TestCase $this->assertTrue($bReceived); } + + public function testInvalidFrameLength() { + $frame = new Frame(str_repeat('a', 200), true, Frame::OP_TEXT); + + $frameRaw = $frame->getContents(); + + $frameRaw[1] = "\x7f"; // 127 in the first spot + + $frameRaw[2] = "\xff"; // this will unpack to -1 + $frameRaw[3] = "\xff"; + $frameRaw[4] = "\xff"; + $frameRaw[5] = "\xff"; + $frameRaw[6] = "\xff"; + $frameRaw[7] = "\xff"; + $frameRaw[8] = "\xff"; + $frameRaw[9] = "\xff"; + + /** @var Frame $controlFrame */ + $controlFrame = null; + $messageCount = 0; + + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) use (&$messageCount) { + $messageCount++; + }, + function (Frame $frame) use (&$controlFrame) { + $this->assertNull($controlFrame); + $controlFrame = $frame; + }, + false, + null, + 0, + 10 + ); + + $messageBuffer->onData($frameRaw); + + $this->assertEquals(0, $messageCount); + $this->assertTrue($controlFrame instanceof Frame); + $this->assertEquals(Frame::OP_CLOSE, $controlFrame->getOpcode()); + $this->assertEquals([Frame::CLOSE_PROTOCOL], array_merge(unpack('n*', substr($controlFrame->getPayload(), 0, 2)))); + + } + + public function testFrameLengthTooBig() { + $frame = new Frame(str_repeat('a', 200), true, Frame::OP_TEXT); + + $frameRaw = $frame->getContents(); + + $frameRaw[1] = "\x7f"; // 127 in the first spot + + $frameRaw[2] = "\x7f"; // this will unpack to -1 + $frameRaw[3] = "\xff"; + $frameRaw[4] = "\xff"; + $frameRaw[5] = "\xff"; + $frameRaw[6] = "\xff"; + $frameRaw[7] = "\xff"; + $frameRaw[8] = "\xff"; + $frameRaw[9] = "\xff"; + + /** @var Frame $controlFrame */ + $controlFrame = null; + $messageCount = 0; + + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) use (&$messageCount) { + $messageCount++; + }, + function (Frame $frame) use (&$controlFrame) { + $this->assertNull($controlFrame); + $controlFrame = $frame; + }, + false, + null, + 0, + 10 + ); + + $messageBuffer->onData($frameRaw); + + $this->assertEquals(0, $messageCount); + $this->assertTrue($controlFrame instanceof Frame); + $this->assertEquals(Frame::OP_CLOSE, $controlFrame->getOpcode()); + $this->assertEquals([Frame::CLOSE_TOO_BIG], array_merge(unpack('n*', substr($controlFrame->getPayload(), 0, 2)))); + } + + public function testFrameLengthBiggerThanMaxMessagePayload() { + $frame = new Frame(str_repeat('a', 200), true, Frame::OP_TEXT); + + $frameRaw = $frame->getContents(); + + /** @var Frame $controlFrame */ + $controlFrame = null; + $messageCount = 0; + + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) use (&$messageCount) { + $messageCount++; + }, + function (Frame $frame) use (&$controlFrame) { + $this->assertNull($controlFrame); + $controlFrame = $frame; + }, + false, + null, + 100, + 0 + ); + + $messageBuffer->onData($frameRaw); + + $this->assertEquals(0, $messageCount); + $this->assertTrue($controlFrame instanceof Frame); + $this->assertEquals(Frame::OP_CLOSE, $controlFrame->getOpcode()); + $this->assertEquals([Frame::CLOSE_TOO_BIG], array_merge(unpack('n*', substr($controlFrame->getPayload(), 0, 2)))); + } + + public function testSecondFrameLengthPushesPastMaxMessagePayload() { + $frame = new Frame(str_repeat('a', 200), false, Frame::OP_TEXT); + $firstFrameRaw = $frame->getContents(); + $frame = new Frame(str_repeat('b', 200), true, Frame::OP_TEXT); + $secondFrameRaw = $frame->getContents(); + + /** @var Frame $controlFrame */ + $controlFrame = null; + $messageCount = 0; + + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) use (&$messageCount) { + $messageCount++; + }, + function (Frame $frame) use (&$controlFrame) { + $this->assertNull($controlFrame); + $controlFrame = $frame; + }, + false, + null, + 300, + 0 + ); + + $messageBuffer->onData($firstFrameRaw); + // only put part of the second frame in to watch it fail fast + $messageBuffer->onData(substr($secondFrameRaw, 0, 150)); + + $this->assertEquals(0, $messageCount); + $this->assertTrue($controlFrame instanceof Frame); + $this->assertEquals(Frame::OP_CLOSE, $controlFrame->getOpcode()); + $this->assertEquals([Frame::CLOSE_TOO_BIG], array_merge(unpack('n*', substr($controlFrame->getPayload(), 0, 2)))); + } + + /** + * Some test cases from memory limit inspired by https://github.com/BrandEmbassy/php-memory + * + * Here is the license for that project: + * MIT License + * + * Copyright (c) 2018 Brand Embassy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + /** + * @dataProvider phpConfigurationProvider + * + * @param string $phpConfigurationValue + * @param int $expectedLimit + */ + public function testMemoryLimits($phpConfigurationValue, $expectedLimit) { + $method = new \ReflectionMethod('Ratchet\RFC6455\Messaging\MessageBuffer', 'getMemoryLimit'); + $method->setAccessible(true); + $actualLimit = $method->invoke(null, $phpConfigurationValue); + + $this->assertSame($expectedLimit, $actualLimit); + } + + public function phpConfigurationProvider() { + return [ + 'without unit type, just bytes' => ['500', 500], + '1 GB with big "G"' => ['1G', 1 * 1024 * 1024 * 1024], + '128 MB with big "M"' => ['128M', 128 * 1024 * 1024], + '128 MB with small "m"' => ['128m', 128 * 1024 * 1024], + '24 kB with small "k"' => ['24k', 24 * 1024], + '2 GB with small "g"' => ['2g', 2 * 1024 * 1024 * 1024], + 'unlimited memory' => ['-1', 0], + 'invalid float value' => ['2.5M', 2 * 1024 * 1024], + 'empty value' => ['', 0], + 'invalid ini setting' => ['whatever it takes', 0] + ]; + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testInvalidMaxFramePayloadSizes() { + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) {}, + function (Frame $frame) {}, + false, + null, + 0, + 0x8000000000000000 + ); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testInvalidMaxMessagePayloadSizes() { + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) {}, + function (Frame $frame) {}, + false, + null, + 0x8000000000000000, + 0 + ); + } + + /** + * @dataProvider phpConfigurationProvider + * + * @param string $phpConfigurationValue + * @param int $expectedLimit + * + * @runInSeparateProcess + * @requires PHP 7.0 + */ + public function testIniSizes($phpConfigurationValue, $expectedLimit) { + ini_set('memory_limit', $phpConfigurationValue); + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) {}, + function (Frame $frame) {}, + false, + null + ); + + if ($expectedLimit === -1) { + $expectedLimit = 0; + } + + $prop = new \ReflectionProperty($messageBuffer, 'maxMessagePayloadSize'); + $prop->setAccessible(true); + $this->assertEquals($expectedLimit / 4, $prop->getValue($messageBuffer)); + + $prop = new \ReflectionProperty($messageBuffer, 'maxFramePayloadSize'); + $prop->setAccessible(true); + $this->assertEquals($expectedLimit / 4, $prop->getValue($messageBuffer)); + } + + /** + * @runInSeparateProcess + * @requires PHP 7.0 + */ + public function testInvalidIniSize() { + ini_set('memory_limit', 'lots of memory'); + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) {}, + function (Frame $frame) {}, + false, + null + ); + + $prop = new \ReflectionProperty($messageBuffer, 'maxMessagePayloadSize'); + $prop->setAccessible(true); + $this->assertEquals(0, $prop->getValue($messageBuffer)); + + $prop = new \ReflectionProperty($messageBuffer, 'maxFramePayloadSize'); + $prop->setAccessible(true); + $this->assertEquals(0, $prop->getValue($messageBuffer)); + } } \ No newline at end of file