diff --git a/README.md b/README.md index 7a5ca69b..1006559f 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,27 @@ $http = new Server($socket, function (RequestInterface $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. +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 +Allowed). + +> The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not + something most HTTP servers would want to care about. + Note that if you want to handle this method, the client MAY send a different + request-target than the `Host` header field (such as removing default ports) + and the request-target MUST take precendence when forwarding. + The HTTP specs define an opaque "tunneling mode" for this method and make no + use of the message body. + For consistency reasons, this library uses the message body of the request and + 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. + ### Response The callback function passed to the constructor of the [Server](#server) @@ -393,14 +414,25 @@ message body as per the HTTP specs. This means that your callback does not have to take special care of this and any response body will simply be ignored. -Similarly, any response with a `1xx` (Informational) or `204` (No Content) -status code will *not* include a `Content-Length` or `Transfer-Encoding` -header as these do not apply to these messages. +Similarly, any `2xx` (Successful) response to a `CONNECT` request, any response +with a `1xx` (Informational) or `204` (No Content) status code will *not* +include a `Content-Length` or `Transfer-Encoding` header as these do not apply +to these messages. Note that a response to a `HEAD` request and any response with a `304` (Not Modified) status code MAY include these headers even though the message does not contain a response body, because these header would apply to the message if the same request would have used an (unconditional) `GET`. +> The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not + something most HTTP servers would want to care about. + The HTTP specs define an opaque "tunneling mode" for this method and make no + use of the message body. + For consistency reasons, this library uses the message body of the request and + 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. + 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/composer.json b/composer.json index ba6ac438..75d769e2 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^4.8.10||^5.0" + "phpunit/phpunit": "^4.8.10||^5.0", + "react/socket-client": "^0.6" } } diff --git a/examples/21-connect-proxy.php b/examples/21-connect-proxy.php new file mode 100644 index 00000000..6ab54f31 --- /dev/null +++ b/examples/21-connect-proxy.php @@ -0,0 +1,77 @@ +create('8.8.8.8', $loop); +$connector = new DnsConnector(new TcpConnector($loop), $resolver); + +$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($connector) { + if ($request->getMethod() !== 'CONNECT') { + return new Response( + 405, + array('Content-Type' => 'text/plain', 'Allow' => 'CONNECT'), + 'This is a HTTP CONNECT (secure HTTPS) proxy' + ); + } + + // pause consuming request body + $body = $request->getBody(); + $body->pause(); + + $buffer = ''; + $body->on('data', function ($chunk) use (&$buffer) { + $buffer .= $chunk; + }); + + // try to connect to given target host + $promise = $connector->connect($request->getRequestTarget())->then( + function (ConnectionInterface $remote) use ($body, &$buffer) { + // connection established => forward data + $body->pipe($remote); + $body->resume(); + + if ($buffer !== '') { + $remote->write($buffer); + $buffer = ''; + } + + return new Response( + 200, + array(), + $remote + ); + }, + function ($e) { + return new Response( + 502, + array('Content-Type' => 'text/plain'), + 'Unable to connect: ' . $e->getMessage() + ); + } + ); + + // cancel pending connection if request closes prematurely + $body->on('close', function () use ($promise) { + $promise->cancel(); + }); + + return $promise; +}); + +//$server->on('error', 'printf'); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index b24325f2..db115248 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -55,18 +55,37 @@ private function parseRequest($data) { list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2); - $asterisk = false; + $originalTarget = null; if (strpos($headers, 'OPTIONS * ') === 0) { - $asterisk = true; + $originalTarget = '*'; $headers = 'OPTIONS / ' . substr($headers, 10); + } elseif (strpos($headers, 'CONNECT ') === 0) { + $parts = explode(' ', $headers, 3); + $uri = parse_url('tcp://' . $parts[1]); + + // check this is a valid authority-form request-target (host:port) + if (isset($uri['scheme'], $uri['host'], $uri['port']) && count($uri) === 3) { + $originalTarget = $parts[1]; + $parts[1] = '/'; + $headers = implode(' ', $parts); + } } $request = g7\parse_request($headers); - if ($asterisk) { + // Do not assume this is HTTPS when this happens to be port 443 + // 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) + ); + } + + if ($originalTarget !== null) { $request = $request->withUri( - $request->getUri()->withPath('') - )->withRequestTarget('*'); + $request->getUri()->withPath(''), + true + )->withRequestTarget($originalTarget); } return array($request, $bodyBuffer); diff --git a/src/Server.php b/src/Server.php index b9f421b7..e081fb62 100644 --- a/src/Server.php +++ b/src/Server.php @@ -199,7 +199,24 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque $contentLength = 0; $stream = new CloseProtectionStream($conn); - if ($request->hasHeader('Transfer-Encoding')) { + if ($request->getMethod() === 'CONNECT') { + // CONNECT method MUST use authority-form request target + $parts = parse_url('tcp://' . $request->getRequestTarget()); + if (!isset($parts['scheme'], $parts['host'], $parts['port']) || count($parts) !== 3) { + $this->emit('error', array(new \InvalidArgumentException('CONNECT method MUST use authority-form request target'))); + return $this->writeError($conn, 400); + } + + // CONNECT uses undelimited body until connection closes + $request = $request->withoutHeader('Transfer-Encoding'); + $request = $request->withoutHeader('Content-Length'); + $contentLength = null; + + // emit end event before the actual close event + $stream->on('close', function () use ($stream) { + $stream->emit('end'); + }); + } else if ($request->hasHeader('Transfer-Encoding')) { if (strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') { $this->emit('error', array(new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding'))); @@ -336,9 +353,9 @@ public function handleResponse(ConnectionInterface $connection, RequestInterface $response = $response->withHeader('Connection', 'close'); } - // response code 1xx and 204 MUST NOT include Content-Length or Transfer-Encoding header + // 2xx response to CONNECT and 1xx and 204 MUST NOT include Content-Length or Transfer-Encoding header $code = $response->getStatusCode(); - if (($code >= 100 && $code < 200) || $code === 204) { + if (($request->getMethod() === 'CONNECT' && $code >= 200 && $code < 300) || ($code >= 100 && $code < 200) || $code === 204) { $response = $response->withoutHeader('Content-Length')->withoutHeader('Transfer-Encoding'); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 4534edaa..b7ddac2f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -66,21 +66,7 @@ public function testRequestEvent() { $i = 0; $requestAssertion = null; - - $buffer = ''; - - $this->connection - ->expects($this->any()) - ->method('write') - ->will( - $this->returnCallback( - function ($data) use (&$buffer) { - $buffer .= $data; - } - ) - ); - - $server = new Server($this->socket, function ($request) use (&$i, &$requestAssertion) { + $server = new Server($this->socket, function (RequestInterface $request) use (&$i, &$requestAssertion) { $i++; $requestAssertion = $request; return \React\Promise\resolve(new Response()); @@ -102,13 +88,80 @@ function ($data) use (&$buffer) { $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); + $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); } + public function testRequestGetWithHostAndCustomPort() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com:8080/', (string)$requestAssertion->getUri()); + $this->assertSame(8080, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:8080', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestGetWithHostAndHttpsPort() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com:443/', (string)$requestAssertion->getUri()); + $this->assertSame(443, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:443', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestGetWithHostAndDefaultPortWillBeIgnored() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); + $this->assertSame(null, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); + } + public function testRequestOptionsAsterisk() { $requestAssertion = null; - $server = new Server($this->socket, function ($request) use (&$requestAssertion) { + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -123,6 +176,95 @@ public function testRequestOptionsAsterisk() $this->assertSame('*', $requestAssertion->getRequestTarget()); $this->assertSame('', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestNonOptionsWithAsteriskRequestTargetWillReject() + { + $server = new Server($this->socket, $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET * HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + } + + public function testRequestConnectAuthorityForm() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('CONNECT', $requestAssertion->getMethod()); + $this->assertSame('example.com:443', $requestAssertion->getRequestTarget()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com:443', (string)$requestAssertion->getUri()); + $this->assertSame(443, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:443', $requestAssertion->getHeaderLine('host')); + } + + public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('CONNECT', $requestAssertion->getMethod()); + $this->assertSame('example.com:80', $requestAssertion->getRequestTarget()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); + $this->assertSame(null, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "CONNECT example.com:80 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('CONNECT', $requestAssertion->getMethod()); + $this->assertSame('example.com:80', $requestAssertion->getRequestTarget()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); + $this->assertSame(null, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('host')); + } + + public function testRequestNonConnectWithAuthorityRequestTargetWillReject() + { + $server = new Server($this->socket, $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET example.com:80 HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); } public function testRequestPauseWillbeForwardedToConnection()