Skip to content

Validate proxy requests in absolute-form #169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 21, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions examples/21-http-proxy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

use React\EventLoop\Factory;
use React\Socket\Server;
use React\Http\Response;
use Psr\Http\Message\RequestInterface;
use RingCentral\Psr7;

require __DIR__ . '/../vendor/autoload.php';

$loop = Factory::create();
$socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);

$server = new \React\Http\Server($socket, function (RequestInterface $request) {
if (strpos($request->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();
File renamed without changes.
16 changes: 16 additions & 0 deletions src/RequestHeaderParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,20 @@ 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(),
$request->getHeaders(),
$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)
Expand All @@ -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);
}
}
32 changes: 32 additions & 0 deletions tests/RequestHeaderParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
111 changes: 111 additions & 0 deletions tests/ServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down