From 1296e1194e6734ca24398bf2a186c7ed935d7991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 3 Mar 2017 10:05:46 +0100 Subject: [PATCH] Validate proxy requests in absolute-form --- README.md | 10 +- examples/21-http-proxy.php | 45 +++++++ ...connect-proxy.php => 22-connect-proxy.php} | 0 src/RequestHeaderParser.php | 16 +++ tests/RequestHeaderParserTest.php | 32 +++++ tests/ServerTest.php | 111 ++++++++++++++++++ 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 examples/21-http-proxy.php rename examples/{21-connect-proxy.php => 22-connect-proxy.php} (100%) diff --git a/README.md b/README.md index 685791cd..74cf2254 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,13 @@ $http = new Server($socket, function (ServerRequestInterface $request) { Note that the server supports *any* request method (including custom and non- standard ones) and all request-target formats defined in the HTTP specs for each -respective method. +respective method, including *normal* `origin-form` requests as well as +proxy requests in `absolute-form` and `authority-form`. +The `getUri(): UriInterface` method can be used to get the effective request +URI which provides you access to individiual URI components. +Note that (depending on the given `request-target`) certain URI components may +or may not be present, for example the `getPath(): string` method will return +an empty string for requests in `asterisk-form` or `authority-form`. You can use `getMethod(): string` and `getRequestTarget(): string` to check this is an accepted request and may want to reject other requests with an appropriate error code, such as `400` (Bad Request) or `405` (Method Not @@ -439,7 +445,7 @@ to the message if the same request would have used an (unconditional) `GET`. response for tunneled application data. This implies that that a `2xx` (Successful) response to a `CONNECT` request can in fact use a streaming response body for the tunneled application data. - See also [example #21](examples) for more details. + See also [example #22](examples) for more details. A `Date` header will be automatically added with the system date and time if none is given. You can add a custom `Date` header yourself like this: diff --git a/examples/21-http-proxy.php b/examples/21-http-proxy.php new file mode 100644 index 00000000..720f51fe --- /dev/null +++ b/examples/21-http-proxy.php @@ -0,0 +1,45 @@ +getRequestTarget(), '://') === false) { + return new Response( + 400, + array('Content-Type' => 'text/plain'), + 'This is a plain HTTP proxy' + ); + } + + // prepare outgoing client request by updating request-target and Host header + $host = (string)$request->getUri()->withScheme('')->withPath('')->withQuery(''); + $target = (string)$request->getUri()->withScheme('')->withHost('')->withPort(null); + if ($target === '') { + $target = $request->getMethod() === 'OPTIONS' ? '*' : '/'; + } + $outgoing = $request->withRequestTarget($target)->withHeader('Host', $host); + + // pseudo code only: simply dump the outgoing request as a string + // left up as an exercise: use an HTTP client to send the outgoing request + // and forward the incoming response to the original client request + return new Response( + 200, + array('Content-Type' => 'text/plain'), + Psr7\str($outgoing) + ); +}); + +//$server->on('error', 'printf'); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/21-connect-proxy.php b/examples/22-connect-proxy.php similarity index 100% rename from examples/21-connect-proxy.php rename to examples/22-connect-proxy.php diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 510de700..97fa7a00 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -71,7 +71,12 @@ private function parseRequest($data) } } + // parse request headers into obj implementing RequestInterface $request = g7\parse_request($headers); + + // create new obj implementing ServerRequestInterface by preserving all + // previous properties and restoring original request target-target + $target = $request->getRequestTarget(); $request = new ServerRequest( $request->getMethod(), $request->getUri(), @@ -79,6 +84,7 @@ private function parseRequest($data) $request->getBody(), $request->getProtocolVersion() ); + $request = $request->withRequestTarget($target); // Do not assume this is HTTPS when this happens to be port 443 // detecting HTTPS is left up to the socket layer (TLS detection) @@ -96,6 +102,16 @@ private function parseRequest($data) )->withRequestTarget($originalTarget); } + // ensure absolute-form request-target contains a valid URI + if (strpos($request->getRequestTarget(), '://') !== false) { + $parts = parse_url($request->getRequestTarget()); + + // make sure value contains valid host component (IP or hostname), but no fragment + if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) { + throw new \InvalidArgumentException('Invalid absolute-form request-target'); + } + } + return array($request, $bodyBuffer); } } diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index dfc25f4f..f0422361 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -167,6 +167,38 @@ public function testGuzzleRequestParseException() $this->assertSame(0, count($parser->listeners('error'))); } + public function testInvalidAbsoluteFormSchemeEmitsError() + { + $error = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', $this->expectCallableNever()); + $parser->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $parser->feed("GET tcp://example.com:80/ HTTP/1.0\r\n\r\n"); + + $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); + } + + public function testInvalidAbsoluteFormWithFragmentEmitsError() + { + $error = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', $this->expectCallableNever()); + $parser->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $parser->feed("GET http://example.com:80/#home HTTP/1.0\r\n\r\n"); + + $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; diff --git a/tests/ServerTest.php b/tests/ServerTest.php index d831aa86..aa4b8f89 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -267,6 +267,117 @@ public function testRequestNonConnectWithAuthorityRequestTargetWillReject() $this->connection->emit('data', array($data)); } + public function testRequestAbsoluteEvent() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET http://example.com/test HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com/test', $requestAssertion->getUri()); + $this->assertSame('/test', $requestAssertion->getUri()->getPath()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestAbsoluteAddsMissingHostEvent() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + $server->on('error', 'printf'); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET http://example.com:8080/test HTTP/1.0\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('http://example.com:8080/test', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com:8080/test', $requestAssertion->getUri()); + $this->assertSame('/test', $requestAssertion->getUri()->getPath()); + $this->assertSame('example.com:8080', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestAbsoluteNonMatchingHostWillBePassedAsIs() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET http://example.com/test HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com/test', $requestAssertion->getUri()); + $this->assertSame('/test', $requestAssertion->getUri()->getPath()); + $this->assertSame('other.example.org', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestOptionsAsteriskEvent() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('OPTIONS', $requestAssertion->getMethod()); + $this->assertSame('*', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com', $requestAssertion->getUri()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestOptionsAbsoluteEvent() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "OPTIONS http://example.com HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('OPTIONS', $requestAssertion->getMethod()); + $this->assertSame('http://example.com', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com', $requestAssertion->getUri()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + public function testRequestPauseWillbeForwardedToConnection() { $server = new Server($this->socket, function (ServerRequestInterface $request) {