From e873b2c435077e3d5ec59482b48d788ce291744a Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 15 Feb 2017 13:09:05 +0100 Subject: [PATCH 1/4] Add LenghtLimitedStream --- src/LengthLimitedStream.php | 103 ++++++++++++++++++++++++++++ tests/LengthLimitedStreamTest.php | 108 ++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/LengthLimitedStream.php create mode 100644 tests/LengthLimitedStreamTest.php diff --git a/src/LengthLimitedStream.php b/src/LengthLimitedStream.php new file mode 100644 index 00000000..6a2d4033 --- /dev/null +++ b/src/LengthLimitedStream.php @@ -0,0 +1,103 @@ +stream = $stream; + $this->maxLength = $maxLength; + + $this->stream->on('data', array($this, 'handleData')); + $this->stream->on('end', array($this, 'handleEnd')); + $this->stream->on('error', array($this, 'handleError')); + $this->stream->on('close', array($this, 'close')); + } + + public function isReadable() + { + return !$this->closed && $this->stream->isReadable(); + } + + public function pause() + { + $this->stream->pause(); + } + + public function resume() + { + $this->stream->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + + $this->stream->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + /** @internal */ + public function handleData($data) + { + if (($this->transferredLength + strlen($data)) > $this->maxLength) { + // Only emit data until the value of 'Content-Length' is reached, the rest will be ignored + $data = (string)substr($data, 0, $this->maxLength - $this->transferredLength); + } + + if ($data !== '') { + $this->transferredLength += strlen($data); + $this->emit('data', array($data)); + } + + if ($this->transferredLength === $this->maxLength) { + // 'Content-Length' reached, stream will end + $this->emit('end'); + $this->close(); + $this->stream->removeListener('data', array($this, 'handleData')); + } + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleEnd() + { + if (!$this->closed) { + $this->emit('end'); + $this->close(); + } + } + +} diff --git a/tests/LengthLimitedStreamTest.php b/tests/LengthLimitedStreamTest.php new file mode 100644 index 00000000..5ba7be0d --- /dev/null +++ b/tests/LengthLimitedStreamTest.php @@ -0,0 +1,108 @@ +input = new ReadableStream(); + } + + public function testSimpleChunk() + { + $stream = new LengthLimitedStream($this->input, 5); + $stream->on('data', $this->expectCallableOnceWith('hello')); + $stream->on('end', $this->expectCallableOnce()); + $this->input->emit('data', array("hello world")); + } + + public function testInputStreamKeepsEmitting() + { + $stream = new LengthLimitedStream($this->input, 5); + $stream->on('data', $this->expectCallableOnceWith('hello')); + $stream->on('end', $this->expectCallableOnce()); + + $this->input->emit('data', array("hello world")); + $this->input->emit('data', array("world")); + $this->input->emit('data', array("world")); + } + + public function testZeroLengthInContentLengthWillIgnoreEmittedDataEvents() + { + $stream = new LengthLimitedStream($this->input, 0); + $stream->on('data', $this->expectCallableNever()); + $stream->on('end', $this->expectCallableOnce()); + $this->input->emit('data', array("hello world")); + } + + public function testHandleError() + { + $stream = new LengthLimitedStream($this->input, 0); + $stream->on('error', $this->expectCallableOnce()); + $stream->on('close', $this->expectCallableOnce()); + + $this->input->emit('error', array(new \RuntimeException())); + + $this->assertFalse($stream->isReadable()); + } + + public function testPauseStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $stream = new LengthLimitedStream($input, 0); + $stream->pause(); + } + + public function testResumeStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $stream = new LengthLimitedStream($input, 0); + $stream->pause(); + $stream->resume(); + } + + public function testPipeStream() + { + $stream = new LengthLimitedStream($this->input, 0); + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $ret = $stream->pipe($dest); + + $this->assertSame($dest, $ret); + } + + public function testHandleClose() + { + $stream = new LengthLimitedStream($this->input, 0); + $stream->on('close', $this->expectCallableOnce()); + + $this->input->close(); + $this->input->emit('end', array()); + + $this->assertFalse($stream->isReadable()); + } + + public function testOutputStreamCanCloseInputStream() + { + $input = new ReadableStream(); + $input->on('close', $this->expectCallableOnce()); + + $stream = new LengthLimitedStream($input, 0); + $stream->on('close', $this->expectCallableOnce()); + + $stream->close(); + + $this->assertFalse($input->isReadable()); + } +} From 60b7dadf4651bc7bc4cee4fa9061df860c8ed525 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 15 Feb 2017 13:10:37 +0100 Subject: [PATCH 2/4] Handle Content-Length requests --- src/Server.php | 19 ++++++ tests/ServerTest.php | 151 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/src/Server.php b/src/Server.php index 5fc182d2..d185276d 100644 --- a/src/Server.php +++ b/src/Server.php @@ -155,6 +155,19 @@ public function handleRequest(ConnectionInterface $conn, Request $request) } } + if ($request->hasHeader('Content-Length')) { + $string = $request->getHeaderLine('Content-Length'); + + $contentLength = (int)$string; + if ((string)$contentLength !== (string)$string) { + // Content-Length value is not an integer or not a single integer + $this->emit('error', new \Exception('The value of `Content-Length` is not valid')); + return; + } + + $stream = new LengthLimitedStream($conn, $contentLength); + } + // forward pause/resume calls to underlying connection $request->on('pause', array($conn, 'pause')); $request->on('resume', array($conn, 'resume')); @@ -173,6 +186,12 @@ public function handleRequest(ConnectionInterface $conn, Request $request) }); $this->emit('request', array($request, $response)); + + if ($stream instanceof LengthLimitedStream && $contentLength === 0) { + // stream must emit an 'end' here, because empty data won't be emitted + $stream->emit('end'); + $conn->removeListener('data', array($stream, 'handleData')); + } } /** @internal */ diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 83330108..a1120b17 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -433,8 +433,8 @@ public function testBodyDataWillBeSendViaRequestEvent() $server = new Server($this->socket); $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableNever(); - $closeEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -708,6 +708,153 @@ public function testRequestHttp10WithoutHostEmitsRequestWithNoError() $this->connection->emit('data', array($data)); } + public function testWontEmitFurtherDataWhenContentLengthIsReached() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + $data .= "hello"; + $data .= "world"; + + $this->connection->emit('data', array($data)); + } + + public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + + $data = "world"; + + $this->connection->emit('data', array($data)); + } + + public function testContentLengthContainsZeroWillEmitEndEvent() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 0\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillBeIgnored() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 0\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + } + + public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillBeIgnoredSplitted() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 0\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $data = "hello"; + + $this->connection->emit('data', array($data)); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From ff818b5b36fd3202bf8e787516081c7e50fb2778 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sun, 19 Feb 2017 18:14:05 +0100 Subject: [PATCH 3/4] Ignore Content-Length if Transfer-Encoding isset instead of replacing --- src/Server.php | 13 ++-- tests/ServerTest.php | 156 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 10 deletions(-) diff --git a/src/Server.php b/src/Server.php index d185276d..ba2ffa4f 100644 --- a/src/Server.php +++ b/src/Server.php @@ -153,16 +153,14 @@ public function handleRequest(ConnectionInterface $conn, Request $request) if (strtolower(end($transferEncodingHeader)) === 'chunked') { $stream = new ChunkedDecoder($conn); } - } - - if ($request->hasHeader('Content-Length')) { + } elseif ($request->hasHeader('Content-Length')) { $string = $request->getHeaderLine('Content-Length'); $contentLength = (int)$string; if ((string)$contentLength !== (string)$string) { // Content-Length value is not an integer or not a single integer - $this->emit('error', new \Exception('The value of `Content-Length` is not valid')); - return; + $this->emit('error', array(new \InvalidArgumentException('The value of `Content-Length` is not valid'))); + return $this->writeError($conn, 400); } $stream = new LengthLimitedStream($conn, $contentLength); @@ -188,9 +186,10 @@ public function handleRequest(ConnectionInterface $conn, Request $request) $this->emit('request', array($request, $response)); if ($stream instanceof LengthLimitedStream && $contentLength === 0) { - // stream must emit an 'end' here, because empty data won't be emitted + // Content-Length is 0 and won't emit further data, + // 'handleData' from LengthLimitedStream won't be called anymore $stream->emit('end'); - $conn->removeListener('data', array($stream, 'handleData')); + $stream->close(); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a1120b17..d1aaed05 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -775,7 +775,7 @@ public function testContentLengthContainsZeroWillEmitEndEvent() $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -802,7 +802,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -830,7 +830,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -855,6 +855,156 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $this->connection->emit('data', array($data)); } + public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $requestValidation = null; + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + $requestValidation = $request; + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 4\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $data = "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + $this->assertEquals('4', $requestValidation->getHeaderLine('Content-Length')); + $this->assertEquals('chunked', $requestValidation->getHeaderLine('Transfer-Encoding')); + } + + public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $requestValidation = null; + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + $requestValidation = $request; + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: hello world\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $data = "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + + // this is valid behavior according to: https://www.ietf.org/rfc/rfc2616.txt chapter 4.4 + $this->assertEquals('hello world', $requestValidation->getHeaderLine('Content-Length')); + $this->assertEquals('chunked', $requestValidation->getHeaderLine('Transfer-Encoding')); + } + + public function testNonIntegerContentLengthValueWillLeadToError() + { + $error = null; + $server = new Server($this->socket); + $server->on('request', $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: bla\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); + $this->assertInstanceOf('InvalidArgumentException', $error); + } + + public function testMultipleIntegerInContentLengthWillLeadToError() + { + $error = null; + $server = new Server($this->socket); + $server->on('request', $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5, 3, 4\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); + $this->assertInstanceOf('InvalidArgumentException', $error); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 39eb913d095477086a6465af49961401e5b5dc4d Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Tue, 21 Feb 2017 11:06:39 +0100 Subject: [PATCH 4/4] Handle unexpected end in LengthLimitedStream --- src/LengthLimitedStream.php | 3 +-- tests/LengthLimitedStreamTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/LengthLimitedStream.php b/src/LengthLimitedStream.php index 6a2d4033..225f9b0b 100644 --- a/src/LengthLimitedStream.php +++ b/src/LengthLimitedStream.php @@ -95,8 +95,7 @@ public function handleError(\Exception $e) public function handleEnd() { if (!$this->closed) { - $this->emit('end'); - $this->close(); + $this->handleError(new \Exception('Unexpected end event')); } } diff --git a/tests/LengthLimitedStreamTest.php b/tests/LengthLimitedStreamTest.php index 5ba7be0d..8e6375d5 100644 --- a/tests/LengthLimitedStreamTest.php +++ b/tests/LengthLimitedStreamTest.php @@ -105,4 +105,16 @@ public function testOutputStreamCanCloseInputStream() $this->assertFalse($input->isReadable()); } + + public function testHandleUnexpectedEnd() + { + $stream = new LengthLimitedStream($this->input, 5); + + $stream->on('data', $this->expectCallableNever()); + $stream->on('close', $this->expectCallableOnce()); + $stream->on('end', $this->expectCallableNever()); + $stream->on('error', $this->expectCallableOnce()); + + $this->input->emit('end'); + } }