diff --git a/composer.json b/composer.json index 4c74c704..fccbc1bd 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ }, "require-dev": { "phpunit/phpunit": "^4.8.10||^5.0", - "react/socket": "^0.7" + "react/socket": "^0.7", + "clue/block-react": "^1.1" } } diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index db115248..792a0604 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -77,7 +77,8 @@ private function parseRequest($data) // detecting HTTPS is left up to the socket layer (TLS detection) if ($request->getUri()->getScheme() === 'https') { $request = $request->withUri( - $request->getUri()->withScheme('http')->withPort(443) + $request->getUri()->withScheme('http')->withPort(443), + true ); } diff --git a/src/Server.php b/src/Server.php index 87ae5054..4cb6d6ea 100644 --- a/src/Server.php +++ b/src/Server.php @@ -254,6 +254,21 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque '[]' ); + // Update request URI to "https" scheme if the connection is encrypted + if ($this->isConnectionEncrypted($conn)) { + // The request URI may omit default ports here, so try to parse port + // from Host header field (if possible) + $port = $request->getUri()->getPort(); + if ($port === null) { + $port = parse_url('tcp://' . $request->getHeaderLine('Host'), PHP_URL_PORT); + } + + $request = $request->withUri( + $request->getUri()->withScheme('https')->withPort($port), + true + ); + } + $callback = $this->callback; $promise = new Promise(function ($resolve, $reject) use ($callback, $request) { $resolve($callback($request)); @@ -384,4 +399,27 @@ private function handleResponseBody(ResponseInterface $response, ConnectionInter $connection->write(Psr7Implementation\str($response)); $stream->pipe($connection); } + + /** + * @param ConnectionInterface $conn + * @return bool + * @codeCoverageIgnore + */ + private function isConnectionEncrypted(ConnectionInterface $conn) + { + // Legacy PHP < 7 does not offer any direct access to check crypto parameters + // We work around by accessing the context options and assume that only + // secure connections *SHOULD* set the "ssl" context options by default. + if (PHP_VERSION_ID < 70000) { + $context = isset($conn->stream) ? stream_context_get_options($conn->stream) : array(); + + return (isset($context['ssl']) && $context['ssl']); + } + + // Modern PHP 7+ offers more reliable access to check crypto parameters + // by checking stream crypto meta data that is only then made available. + $meta = isset($conn->stream) ? stream_get_meta_data($conn->stream) : array(); + + return (isset($meta['crypto']) && $meta['crypto']); + } } diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php new file mode 100644 index 00000000..4c7ed6ff --- /dev/null +++ b/tests/FunctionalServerTest.php @@ -0,0 +1,204 @@ +getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://' . $socket->getAddress() . '/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnRandomPort() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $connector = new Connector($loop, array( + 'tls' => array('verify_peer' => false) + )); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('https://' . $socket->getAddress() . '/', $response); + + $socket->close(); + } + + public function testPlainHttpOnStandardPortReturnsUriWithNoPort() + { + $loop = Factory::create(); + try { + $socket = new Socket(80, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 80 failed (root and unused?)'); + } + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://127.0.0.1/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + try { + $socket = new Socket(443, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 443 failed (root and unused?)'); + } + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $connector = new Connector($loop, array( + 'tls' => array('verify_peer' => false) + )); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('https://127.0.0.1/', $response); + + $socket->close(); + } + + public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() + { + $loop = Factory::create(); + try { + $socket = new Socket(443, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 443 failed (root and unused?)'); + } + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://127.0.0.1:443/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + try { + $socket = new Socket(80, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 80 failed (root and unused?)'); + } + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $connector = new Connector($loop, array( + 'tls' => array('verify_peer' => false) + )); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri() . 'x' . $request->getHeaderLine('Host')); + }); + + $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('https://127.0.0.1:80/', $response); + + $socket->close(); + } +}