Skip to content

Commit

Permalink
Fix #124 - I hereby declare that I know what I'm doing
Browse files Browse the repository at this point in the history
Due to vhosts now being explicitly keyed with the interface (and TLS being bound to a specific interface), it is not possible to get an unencrypted vhost on an encrypted connection and vice versa.

It is now permitted to set a specific expected port the client must specify in the Host header or wildcard port ("*") to avoid any port matching there.
  • Loading branch information
bwoebi committed Sep 8, 2017
1 parent 23dfd63 commit f6ae3bd
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 114 deletions.
16 changes: 10 additions & 6 deletions lib/Host.php
Expand Up @@ -3,7 +3,7 @@
namespace Aerys;

class Host {
private $name = "";
private $name = "*";
private $interfaces = null;
private $crypto = [];
private $actions = [];
Expand All @@ -16,8 +16,8 @@ class Host {
* "all IPv4 interfaces" and is appropriate for most users. Use "::" to indicate "all IPv6
* interfaces". To indicate "all IPv4 *and* IPv6 interfaces", use a "*" wildcard character.
*
* Note that "::" may also listen on some systems on IPv4 interfaces. PHP currently does
* not expose the IPV6_V6ONLY constant.
* Note that "::" may also listen on some systems on IPv4 interfaces. PHP did not expose the
* IPV6_V6ONLY constant before PHP 7.0.1.
*
* Any valid port number [1-65535] may be used. Port numbers lower than 256 are reserved for
* well-known services (like HTTP on port 80) and port numbers less than 1024 require root
Expand Down Expand Up @@ -57,14 +57,18 @@ public function expose(string $address, int $port): Host {
/**
* Assign a domain name (e.g. localhost or mysite.com or subdomain.mysite.com).
*
* A host name is only required if a server exposes more than one host. If a name is not defined
* the server will default to "localhost"
* An explicit host name is only required if a server exposes more than one host on a given
* interface. If a name is not defined (or "*") the server will allow any hostname.
*
* By default the port must match with the interface. It is possible to explicitly require
* a specific port in the hostname by appending ":port" (e.g. "localhost:8080"). It is also
* possible to specify a wildcard with "*" (e.g. "*:*" to accept any hostname from any port).
*
* @param string $name
* @return self
*/
public function name(string $name): Host {
$this->name = $name;
$this->name = $name === "" ? "*" : $name;

return $this;
}
Expand Down
31 changes: 18 additions & 13 deletions lib/Vhost.php
Expand Up @@ -65,18 +65,17 @@ public function __construct(string $name, array $interfaces, callable $applicati
$this->tlsDefaults["alpn_protocols"] = "h2";
}

if ($this->name !== '') {
$addresses = [$this->name];
} else {
$addresses = array_unique(array_column($interfaces, 0));
}
$ports = array_unique(array_column($interfaces, 1));
foreach ($addresses as $address) {
if (strpos($address, ":") !== false) {
$name = explode(":", $this->name)[0];
$namePort = substr(strstr($this->name, ":"), 1);

foreach ($this->addressMap as $packedAddress => $ports) {
$address = inet_ntop($packedAddress);
if (strlen($packedAddress) === 16) {
$address = "[$address]";
}

foreach ($ports as $port) {
$this->ids[] = "$address:$port";
$this->ids[] = "$name:" . ($namePort === false ? $port : (int) $namePort) . ":$address:$port";
}
}
}
Expand All @@ -96,7 +95,13 @@ private function addInterface(array $interface) {
);
}

$this->interfaces[] = [$address, $port];
if (isset($this->addressMap[$packedAddress]) && in_array($port, $this->addressMap[$packedAddress])) {
throw new \Error(
"There must be no two identical interfaces for a same host"
);
}

$this->interfaces[] = [inet_ntop($packedAddress), $port];
$this->addressMap[$packedAddress][] = $port;
}

Expand Down Expand Up @@ -173,7 +178,7 @@ public function getPorts(string $address): array {
} elseif (!isset($this->addressMap[$packedAddress])) {
return $this->addressMap[$wildcard];
}
return array_merge($this->addressMap[$wildcard], $this->addressMap[$packedAddress]);
return array_merge($this->addressMap[$packedAddress], $this->addressMap[$wildcard]);
}

public function getHttpDriver() {
Expand All @@ -186,7 +191,7 @@ public function getHttpDriver() {
* @return bool
*/
public function hasName(): bool {
return ($this->name !== "");
return $this->name !== "*" && strstr($this->name, ":", true) !== "*";
}

/**
Expand Down Expand Up @@ -241,7 +246,7 @@ public function setCrypto(array $tls) {
}

$names = $this->parseNamesFromTlsCertArray($cert);
if ($this->name != "" && !in_array($this->name, $names)) {
if ($this->hasName() && !in_array(explode(":", $this->name)[0], $names)) {
trigger_error(
"TLS certificate `{$certBase}` has no CN or SAN name match for host `{$this}`; " .
"web browsers will not trust the validity of your certificate :(",
Expand Down
113 changes: 50 additions & 63 deletions lib/VhostContainer.php
Expand Up @@ -5,7 +5,6 @@
class VhostContainer implements \Countable, Monitor {
private $vhosts = [];
private $cachedVhostCount = 0;
private $defaultHost;
private $httpDrivers = [];
private $defaultHttpDriver;
private $setupHttpDrivers = [];
Expand All @@ -26,10 +25,11 @@ public function use(Vhost $vhost) {
$this->preventCryptoSocketConflict($vhost);
foreach ($vhost->getIds() as $id) {
if (isset($this->vhosts[$id])) {
list($host, $port, $interfaceAddr, $interfacePort) = explode(":", $id);
throw new \LogicException(
$vhost->getName() == ""
? "Cannot have two default hosts on the same `$id` interface"
: "Cannot have two hosts with the same `$id` name"
$host === "*"
? "Cannot have two default hosts " . ($interfacePort == $port ? "" : "on port $port ") . "on the same interface ($interfaceAddr:$interfacePort)"
: "Cannot have two hosts with the same name ($host" . ($interfacePort == $port ? "" : ":$port") . ") on the same interface ($interfaceAddr:$interfacePort)"
);
}

Expand All @@ -39,6 +39,7 @@ public function use(Vhost $vhost) {
$this->cachedVhostCount++;
}

// TLS is inherently bound to a specific interface. Unencrypted wildcard hosts will not work on encrypted interfaces and vice versa.
private function preventCryptoSocketConflict(Vhost $new) {
foreach ($this->vhosts as $old) {
// If both hosts are encrypted or both unencrypted there is no conflict
Expand All @@ -50,9 +51,9 @@ private function preventCryptoSocketConflict(Vhost $new) {
throw new \Error(
sprintf(
"Cannot register encrypted host `%s`; unencrypted " .
"host `%s` registered on conflicting port `%s`",
($new->IsEncrypted() ? $new->getName() : $old->getName()) ?: "*",
($new->IsEncrypted() ? $old->getName() : $new->getName()) ?: "*",
"host `%s` registered on conflicting interface `%s`",
$new->IsEncrypted() ? $new->getName() : $old->getName(),
$new->IsEncrypted() ? $old->getName() : $new->getName(),
"$address:$port"
)
);
Expand All @@ -64,8 +65,8 @@ private function preventCryptoSocketConflict(Vhost $new) {
private function addHttpDriver(Vhost $vhost) {
$driver = $vhost->getHttpDriver() ?? $this->defaultHttpDriver;
foreach ($vhost->getInterfaces() as list($address, $port)) {
$generic = $this->httpDrivers[$port][\strlen(inet_pton($address)) === 4 ? "0.0.0.0" : "::"] ?? $driver;
if (($this->httpDrivers[$port][$address] ?? $generic) !== $driver) {
$defaultDriver = $this->httpDrivers[$port][\strlen(inet_pton($address)) === 4 ? "0.0.0.0" : "::"] ?? $driver;
if (($this->httpDrivers[$port][$address] ?? $defaultDriver) !== $driver) {
throw new \Error(
"Cannot use two different HttpDriver instances on an equivalent address-port pair"
);
Expand Down Expand Up @@ -110,81 +111,68 @@ public function setupHttpDrivers(...$args) {
*/
public function selectHttpDriver($address, $port) {
return $this->httpDrivers[$port][$address] ??
$this->httpDrivers[$port][\strlen(inet_pton($address)) === 4 ? "0.0.0.0" : "::"];
$this->httpDrivers[$port][\strpos($address, ":") === false ? "0.0.0.0" : "::"];
}

/**
* Select a virtual host match for the specified request according to RFC 7230 criteria.
*
* Note: For HTTP/1.0 requests (aka omitting a Host header), a proper Vhost will only ever be returned
* if there is a matching wildcard host.
*
* @param \Aerys\InternalRequest $ireq
* @return Vhost|null Returns a Vhost object and boolean TRUE if a valid host selected, FALSE otherwise
* @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.2
* @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html#sec19.6.1.1
*/
public function selectHost(InternalRequest $ireq) {
if (isset($ireq->uriHost)) {
return $this->selectHostByAuthority($ireq);
$client = $ireq->client;
$serverId = ":{$client->serverAddr}:{$client->serverPort}";

$explicitHostId = "{$ireq->uriHost}:{$ireq->uriPort}{$serverId}";
if (isset($this->vhosts[$explicitHostId])) {
return $this->vhosts[$explicitHostId];
}
return null;

$addressWildcardHost = "*:{$ireq->uriPort}{$serverId}";
if (isset($this->vhosts[$addressWildcardHost])) {
return $this->vhosts[$addressWildcardHost];
}

// If null is returned a stream must return 400 for HTTP/1.1 requests and use the default
// host for HTTP/1.0 requests.
}
$portWildcardHostId = "{$ireq->uriHost}:0{$serverId}";
if (isset($this->vhosts[$portWildcardHostId])) {
return $this->vhosts[$portWildcardHostId];
}

/**
* Retrieve the group's default host.
*
* @return \Aerys\Vhost
*/
public function getDefaultHost(): Vhost {
if ($this->defaultHost) {
return $this->defaultHost;
} elseif ($this->cachedVhostCount) {
return current($this->vhosts);
}
throw new \Error(
"Cannot retrieve default host; no Vhost instances added to the group"
);
}
$addressPortWildcardHost = "*:0{$serverId}";
if (isset($this->vhosts[$addressPortWildcardHost])) {
return $this->vhosts[$addressPortWildcardHost];
}

private function selectHostByAuthority(InternalRequest $ireq) {
$explicitHostId = "{$ireq->uriHost}:{$ireq->uriPort}";
$wildcardHost = "0.0.0.0:{$ireq->uriPort}";
$ipv6WildcardHost = "[::]:{$ireq->uriPort}";
$wildcardIP = \strpos($client->serverAddr, ":") === false ? "0.0.0.0" : "[::]";
$serverId = ":$wildcardIP:{$client->serverPort}";

$explicitHostId = "{$ireq->uriHost}:{$ireq->uriPort}{$serverId}";
if (isset($this->vhosts[$explicitHostId])) {
$vhost = $this->vhosts[$explicitHostId];
} elseif (isset($this->vhosts[$wildcardHost])) {
$vhost = $this->vhosts[$wildcardHost];
} elseif (isset($this->vhosts[$ipv6WildcardHost])) {
$vhost = $this->vhosts[$ipv6WildcardHost];
} elseif ($this->cachedVhostCount !== 1) {
return null;
} else {
$ipComparison = $ireq->uriHost;

if (!@inet_pton($ipComparison)) {
$ipComparison = (string) substr($ipComparison, 1, -1); // IPv6 braces
if (!@inet_pton($ipComparison)) {
return null;
}
}
if (!(($vhost = $this->getDefaultHost()) && in_array($ireq->uriPort, $vhost->getPorts($ipComparison)))) {
return null;
}
return $this->vhosts[$explicitHostId];
}

$addressWildcardHost = "*:{$ireq->uriPort}{$serverId}";
if (isset($this->vhosts[$addressWildcardHost])) {
return $this->vhosts[$addressWildcardHost];
}

$portWildcardHostId = "{$ireq->uriHost}:0{$serverId}";
if (isset($this->vhosts[$portWildcardHostId])) {
return $this->vhosts[$portWildcardHostId];
}

// IMPORTANT: Wildcard IP hosts without names that are running both encrypted and plaintext
// apps on the same interface (via separate ports) must be checked for encryption to avoid
// displaying unencrypted data as a result of carefully crafted Host headers. This is an
// extreme edge case but it's potentially exploitable without this check.
// DO NOT REMOVE THIS UNLESS YOU'RE SURE YOU KNOW WHAT YOU'RE DOING.
if ($vhost->isEncrypted() != $ireq->client->isEncrypted) {
return null;
$addressPortWildcardHost = "*:0{$serverId}";
if (isset($this->vhosts[$addressPortWildcardHost])) {
return $this->vhosts[$addressPortWildcardHost];
}

return $vhost;
return null; // nothing found
}

/**
Expand Down Expand Up @@ -239,7 +227,6 @@ public function count() {
public function __debugInfo() {
return [
"vhosts" => $this->vhosts,
"defaultHost" => $this->defaultHost,
];
}

Expand Down
3 changes: 2 additions & 1 deletion test/ClientTest.php
Expand Up @@ -121,8 +121,9 @@ public function testClientDisconnect() {

$context = (new ClientTlsContext)->withoutPeerVerification();
$client = new DefaultClient(null, null, $context);
$port = parse_url($address, PHP_URL_PORT);
$promise = $client->request(
(new \Amp\Artax\Request("https://$address/", "POST"))->withBody("body")
(new \Amp\Artax\Request("https://localhost:$port/", "POST"))->withBody("body")
);

$response = yield $promise;
Expand Down

0 comments on commit f6ae3bd

Please sign in to comment.