Skip to content
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

Sanitize Host header value across all requests #173

Merged
merged 4 commits into from
Apr 22, 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
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