Skip to content

Commit

Permalink
Merge pull request #173 from clue-labs/host
Browse files Browse the repository at this point in the history
Sanitize Host header value across all requests
  • Loading branch information
WyriHaximus authored Apr 22, 2017
2 parents 194658a + a60e202 commit a3b1a84
Show file tree
Hide file tree
Showing 6 changed files with 371 additions and 114 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,15 @@ 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`.
Its `getHost(): string` method will return the host as determined by the
effective request URI, which defaults to the local socket address if a HTTP/1.0
client did not specify one (i.e. no `Host` header).
Its `getScheme(): string` method will return `http` or `https` depending
on whether the request was made over a secure TLS connection to the target host.

The `Host` header value will be sanitized to match this host component plus the
port component only if it is non-standard for this URI scheme.

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 All @@ -294,7 +303,7 @@ 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)
request-target than the `Host` header value (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.
Expand Down
92 changes: 80 additions & 12 deletions src/RequestHeaderParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ class RequestHeaderParser extends EventEmitter
private $buffer = '';
private $maxSize = 4096;

private $uri;

public function __construct($localSocketUri = '')
{
$this->uri = $localSocketUri;
}

public function feed($data)
{
$this->buffer .= $data;
Expand All @@ -30,7 +37,7 @@ public function feed($data)
}

if ($currentHeaderSize > $this->maxSize) {
$this->emit('error', array(new \OverflowException("Maximum header size of {$this->maxSize} exceeded."), $this));
$this->emit('error', array(new \OverflowException("Maximum header size of {$this->maxSize} exceeded.", 431), $this));
$this->removeAllListeners();
return;
}
Expand All @@ -55,6 +62,8 @@ private function parseRequest($data)
{
list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2);

// parser does not support asterisk-form and authority-form
// remember original target and temporarily replace and re-apply below
$originalTarget = null;
if (strpos($headers, 'OPTIONS * ') === 0) {
$originalTarget = '*';
Expand All @@ -75,7 +84,7 @@ private function parseRequest($data)
$request = g7\parse_request($headers);

// create new obj implementing ServerRequestInterface by preserving all
// previous properties and restoring original request target-target
// previous properties and restoring original request-target
$target = $request->getRequestTarget();
$request = new ServerRequest(
$request->getMethod(),
Expand All @@ -86,22 +95,27 @@ private function parseRequest($data)
);
$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)
if ($request->getUri()->getScheme() === 'https') {
$request = $request->withUri(
$request->getUri()->withScheme('http')->withPort(443),
true
);
}

// re-apply actual request target from above
if ($originalTarget !== null) {
$uri = $request->getUri()->withPath('');

// re-apply host and port from request-target if given
$parts = parse_url('tcp://' . $originalTarget);
if (isset($parts['host'], $parts['port'])) {
$uri = $uri->withHost($parts['host'])->withPort($parts['port']);
}

$request = $request->withUri(
$request->getUri()->withPath(''),
$uri,
true
)->withRequestTarget($originalTarget);
}

// only support HTTP/1.1 and HTTP/1.0 requests
if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') {
throw new \InvalidArgumentException('Received request with invalid protocol version', 505);
}

// ensure absolute-form request-target contains a valid URI
if (strpos($request->getRequestTarget(), '://') !== false) {
$parts = parse_url($request->getRequestTarget());
Expand All @@ -112,6 +126,60 @@ private function parseRequest($data)
}
}

// Optional Host header value MUST be valid (host and optional port)
if ($request->hasHeader('Host')) {
$parts = parse_url('http://' . $request->getHeaderLine('Host'));

// make sure value contains valid host component (IP or hostname)
if (!$parts || !isset($parts['scheme'], $parts['host'])) {
$parts = false;
}

// make sure value does not contain any other URI component
unset($parts['scheme'], $parts['host'], $parts['port']);
if ($parts === false || $parts) {
throw new \InvalidArgumentException('Invalid Host header value');
}
}

// set URI components from socket address if not already filled via Host header
if ($request->getUri()->getHost() === '') {
$parts = parse_url($this->uri);

$request = $request->withUri(
$request->getUri()->withScheme('http')->withHost($parts['host'])->withPort($parts['port']),
true
);
}

// 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),
true
);
}

// Update request URI to "https" scheme if the connection is encrypted
$parts = parse_url($this->uri);
if (isset($parts['scheme']) && $parts['scheme'] === 'https') {
// 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); // @codeCoverageIgnore
}

$request = $request->withUri(
$request->getUri()->withScheme('https')->withPort($port),
true
);
}

// always sanitize Host header because it contains critical routing information
$request = $request->withUri($request->getUri()->withUserInfo('u')->withUserInfo(''));

return array($request, $bodyBuffer);
}
}
47 changes: 5 additions & 42 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ public function __construct(SocketServerInterface $io, $callback)
public function handleConnection(ConnectionInterface $conn)
{
$that = $this;
$parser = new RequestHeaderParser();
$parser = new RequestHeaderParser(
($this->isConnectionEncrypted($conn) ? 'https://' : 'http://') . $conn->getLocalAddress()
);

$listener = array($parser, 'feed');
$parser->on('headers', function (RequestInterface $request, $bodyBuffer) use ($conn, $listener, $parser, $that) {
// parsing request completed => stop feeding parser
Expand All @@ -165,39 +168,14 @@ public function handleConnection(ConnectionInterface $conn)

$that->writeError(
$conn,
($e instanceof \OverflowException) ? 431 : 400
$e->getCode() !== 0 ? $e->getCode() : 400
);
});
}

/** @internal */
public function handleRequest(ConnectionInterface $conn, ServerRequestInterface $request)
{
// only support HTTP/1.1 and HTTP/1.0 requests
if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') {
$this->emit('error', array(new \InvalidArgumentException('Received request with invalid protocol version')));
$request = $request->withProtocolVersion('1.1');
return $this->writeError($conn, 505, $request);
}

// HTTP/1.1 requests MUST include a valid host header (host and optional port)
// https://tools.ietf.org/html/rfc7230#section-5.4
if ($request->getProtocolVersion() === '1.1') {
$parts = parse_url('http://' . $request->getHeaderLine('Host'));

// make sure value contains valid host component (IP or hostname)
if (!$parts || !isset($parts['scheme'], $parts['host'])) {
$parts = false;
}

// make sure value does not contain any other URI component
unset($parts['scheme'], $parts['host'], $parts['port']);
if ($parts === false || $parts) {
$this->emit('error', array(new \InvalidArgumentException('Invalid Host header for HTTP/1.1 request')));
return $this->writeError($conn, 400, $request);
}
}

$contentLength = 0;
$stream = new CloseProtectionStream($conn);
if ($request->getMethod() === 'CONNECT') {
Expand Down Expand Up @@ -255,21 +233,6 @@ public function handleRequest(ConnectionInterface $conn, ServerRequestInterface
'[]'
);

// 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));
Expand Down
Loading

0 comments on commit a3b1a84

Please sign in to comment.