diff --git a/src/Composer/Util/NoProxyPattern.php b/src/Composer/Util/NoProxyPattern.php index a6cb112be..adf60f0f7 100644 --- a/src/Composer/Util/NoProxyPattern.php +++ b/src/Composer/Util/NoProxyPattern.php @@ -12,34 +12,76 @@ namespace Composer\Util; +use stdClass; + /** - * Tests URLs against no_proxy patterns. + * Tests URLs against NO_PROXY patterns */ class NoProxyPattern { /** * @var string[] */ + protected $hostNames = array(); + + /** + * @var object[] + */ protected $rules = array(); /** - * @param string $pattern no_proxy pattern + * @var bool + */ + protected $noproxy; + + /** + * @param string $pattern NO_PROXY pattern */ public function __construct($pattern) { - $this->rules = preg_split("/[\s,]+/", $pattern); + $this->hostNames = preg_split('{[\s,]+}', $pattern, null, PREG_SPLIT_NO_EMPTY); + $this->noproxy = empty($this->hostNames) || '*' === $this->hostNames[0]; } /** - * Test a URL against the stored pattern. + * Returns true if a URL matches the NO_PROXY pattern * * @param string $url * - * @return bool true if the URL matches one of the rules. + * @return bool */ public function test($url) { - $host = parse_url($url, PHP_URL_HOST); + if ($this->noproxy) { + return true; + } + + if (!$urlData = $this->getUrlData($url)) { + return false; + } + + foreach ($this->hostNames as $index => $hostName) { + if ($this->match($index, $hostName, $urlData)) { + return true; + } + } + + return false; + } + + /** + * Returns false is the url cannot be parsed, otherwise a data object + * + * @param string $url + * + * @return bool|stdclass + */ + protected function getUrlData($url) + { + if (!$host = parse_url($url, PHP_URL_HOST)) { + return false; + } + $port = parse_url($url, PHP_URL_PORT); if (empty($port)) { @@ -53,95 +95,341 @@ class NoProxyPattern } } - foreach ($this->rules as $rule) { - if ($rule == '*') { - return true; + $hostName = $host . ($port ? ':' . $port : ''); + list($host, $port, $err) = $this->splitHostPort($hostName); + + if ($err || !$this->ipCheckData($host, $ipdata)) { + return false; + } + + return $this->makeData($host, $port, $ipdata); + } + + /** + * Returns true if the url is matched by a rule + * + * @param int $index + * @param string $hostName + * @param string $url + * + * @return bool + */ + protected function match($index, $hostName, $url) + { + if (!$rule = $this->getRule($index, $hostName)) { + // Data must have been misformatted + return false; + } + + if ($rule->ipdata) { + // Match ipdata first + if (!$url->ipdata) { + return false; } - $match = false; - - list($ruleHost) = explode(':', $rule); - list($base) = explode('/', $ruleHost); - - if (filter_var($base, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - // ip or cidr match - - if (!isset($ip)) { - $ip = gethostbyname($host); - } - - if (strpos($ruleHost, '/') === false) { - $match = $ip === $ruleHost; - } else { - // gethostbyname() failed to resolve $host to an ip, so we assume - // it must be proxied to let the proxy's DNS resolve it - if ($ip === $host) { - $match = false; - } else { - // match resolved IP against the rule - $match = self::inCIDRBlock($ruleHost, $ip); - } - } - } else { - // match end of domain - - $haystack = '.' . trim($host, '.') . '.'; - $needle = '.'. trim($ruleHost, '.') .'.'; - $match = stripos(strrev($haystack), strrev($needle)) === 0; + if ($rule->ipdata->netmask) { + return $this->matchRange($rule->ipdata, $url->ipdata); } - // final port check - if ($match && strpos($rule, ':') !== false) { - list(, $rulePort) = explode(':', $rule); - if (!empty($rulePort) && $port != $rulePort) { - $match = false; - } + $match = $rule->ipdata->ip === $url->ipdata->ip; + } else { + // Match host and port + $haystack = substr($url->name, - strlen($rule->name)); + $match = stripos($haystack, $rule->name) === 0; + } + + if ($match && $rule->port) { + $match = $rule->port === $url->port; + } + + return $match; + } + + /** + * Returns true if the target ip is in the network range + * + * @param stdClass $network + * @param stdClass $target + * + * @return bool + */ + protected function matchRange(stdClass $network, stdClass $target) + { + $net = unpack('C*', $network->ip); + $mask = unpack('C*', $network->netmask); + $ip = unpack('C*', $target->ip); + + for ($i = 1; $i < 17; ++$i) { + if (($net[$i] & $mask[$i]) !== ($ip[$i] & $mask[$i])) { + return false; } + } - if ($match) { - return true; + return true; + } + + /** + * Finds or creates rule data for a hostname + * + * @param int $index + * @param string $hostName + * + * @return {null|stdClass} Null if the hostname is invalid + */ + private function getRule($index, $hostName) + { + if (array_key_exists($index, $this->rules)) { + return $this->rules[$index]; + } + + $this->rules[$index] = null; + list($host, $port, $err) = $this->splitHostPort($hostName); + + if ($err || !$this->ipCheckData($host, $ipdata, true)) { + return null; + } + + $this->rules[$index] = $this->makeData($host, $port, $ipdata); + + return $this->rules[$index]; + } + + /** + * Creates an object containing IP data if the host is an IP address + * + * @param string $host + * @param null|stdclass $ipdata Set by method if IP address found + * @param bool $allowPrefix Whether a CIDR prefix-length is expected + * + * @return bool False if the host contains invalid data + */ + private function ipCheckData($host, &$ipdata, $allowPrefix = false) + { + $ipdata = null; + $netmask = null; + $prefix = null; + $modified = false; + + // Check for a CIDR prefix-length + if (strpos($host, '/') !== false) { + list($host, $prefix) = explode('/', $host); + + if (!$allowPrefix || !$this->validateInt($prefix, 0, 128)) { + return false; } + $prefix = (int) $prefix; + $modified = true; } - return false; + // See if this is an ip address + if (!filter_var($host, FILTER_VALIDATE_IP)) { + return !$modified; + } + + list($ip, $size) = $this->ipGetAddr($host); + + if ($prefix !== null) { + // Check for a valid prefix + if ($prefix > $size * 8) { + return false; + } + + list($ip, $netmask) = $this->ipGetNetwork($ip, $size, $prefix); + } + + $ipdata = $this->makeIpData($ip, $size, $netmask); + + return true; } /** - * Check an IP address against a CIDR + * Returns an array of the IP in_addr and its byte size * - * http://framework.zend.com/svn/framework/extras/incubator/library/ZendX/Whois/Adapter/Cidr.php + * IPv4 addresses are always mapped to IPv6, which simplifies handling + * and comparison. * - * @param string $cidr IPv4 block in CIDR notation - * @param string $ip IPv4 address + * @param string $host * - * @return bool + * @return mixed[] in_addr, size + */ + private function ipGetAddr($host) + { + $ip = inet_pton($host); + $size = strlen($ip); + $mapped = $this->ipMapTo6($ip, $size); + + return array($mapped, $size); + } + + /** + * Returns the binary network mask mapped to IPv6 + * + * @param string $prefix CIDR prefix-length + * @param int $size Byte size of in_addr + * + * @return string + */ + private function ipGetMask($prefix, $size) + { + $mask = ''; + + if ($ones = floor($prefix / 8)) { + $mask = str_repeat(chr(255), $ones); + } + + if ($remainder = $prefix % 8) { + $mask .= chr(0xff ^ (0xff >> $remainder)); + } + + $mask = str_pad($mask, $size, chr(0)); + + return $this->ipMapTo6($mask, $size); + } + + /** + * Calculates and returns the network and mask + * + * @param string $rangeIp IP in_addr + * @param int $size Byte size of in_addr + * @param string $prefix CIDR prefix-length + * + * @return string[] network in_addr, binary mask */ - private static function inCIDRBlock($cidr, $ip) + private function ipGetNetwork($rangeIp, $size, $prefix) { - // Get the base and the bits from the CIDR - list($base, $bits) = explode('/', $cidr); + $netmask = $this->ipGetMask($prefix, $size); - // Now split it up into it's classes - list($a, $b, $c, $d) = explode('.', $base); + // Get the network from the address and mask + $mask = unpack('C*', $netmask); + $ip = unpack('C*', $rangeIp); + $net = ''; - // Now do some bit shifting/switching to convert to ints - $i = ($a << 24) + ($b << 16) + ($c << 8) + $d; - $mask = $bits == 0 ? 0 : (~0 << (32 - $bits)); + for ($i = 1; $i < 17; ++$i) { + $net .= chr($ip[$i] & $mask[$i]); + } - // Here's our lowest int - $low = $i & $mask; + return array($net, $netmask); + } - // Here's our highest int - $high = $i | (~$mask & 0xFFFFFFFF); + /** + * Maps an IPv4 address to IPv6 + * + * @param string $binary in_addr + * @param int $size Byte size of in_addr + * + * @return string Mapped or existing in_addr + */ + private function ipMapTo6($binary, $size) + { + if ($size === 4) { + $prefix = str_repeat(chr(0), 10) . str_repeat(chr(255), 2); + $binary = $prefix . $binary; + } - // Now split the ip we're checking against up into classes - list($a, $b, $c, $d) = explode('.', $ip); + return $binary; + } - // Now convert the ip we're checking against to an int - $check = ($a << 24) + ($b << 16) + ($c << 8) + $d; + /** + * Creates a rule data object + * + * @param string $host + * @param int $port + * @param null|stdclass $ipdata + * + * @return stdclass + */ + private function makeData($host, $port, $ipdata) + { + return (object) array( + 'host' => $host, + 'name' => '.' . ltrim($host, '.'), + 'port' => $port, + 'ipdata' => $ipdata, + ); + } + + /** + * Creates an ip data object + * + * @param string $ip in_addr + * @param int $size Byte size of in_addr + * @param null|string $netmask Network mask + * + * @return stdclass + */ + private function makeIpData($ip, $size, $netmask) + { + return (object) array( + 'ip' => $ip, + 'size' => $size, + 'netmask' => $netmask, + ); + } + + /** + * Splits the hostname into host and port components + * + * @param string $hostName + * + * @return mixed[] host, port, if there was error + */ + private function splitHostPort($hostName) + { + // host, port, err + $error = array('', '', true); + $port = 0; + $ip6 = ''; + + // Check for square-bracket notation + if ($hostName[0] === '[') { + $index = strpos($hostName, ']'); + + // The smallest ip6 address is :: + if (false === $index || $index < 3) { + return $error; + } + + $ip6 = substr($hostName, 1, $index - 1); + $hostName = substr($hostName, $index + 1); + + if (strpbrk($hostName, '[]') !== false + || substr_count($hostName, ':') > 1) { + return $error; + } + } + + if (substr_count($hostName, ':') === 1) { + $index = strpos($hostName, ':'); + $port = substr($hostName, $index + 1); + $hostName = substr($hostName, 0, $index); + + if (!$this->validateInt($port, 1, 65535)) { + return $error; + } + + $port = (int) $port; + } + + $host = $ip6 . $hostName; + + return array($host, $port, false); + } + + /** + * Wrapper around filter_var FILTER_VALIDATE_INT + * + * @param string $int + * @param int $min + * @param int $max + */ + private function validateInt($int, $min, $max) + { + $options = array( + 'options' => array( + 'min_range' => $min, + 'max_range' => $max) + ); - // If the ip is within the range, including highest/lowest values, - // then it's within the CIDR range - return $check >= $low && $check <= $high; + return false !== filter_var($int, FILTER_VALIDATE_INT, $options); } } diff --git a/tests/Composer/Test/Util/NoProxyPatternTest.php b/tests/Composer/Test/Util/NoProxyPatternTest.php new file mode 100644 index 000000000..a3c01546c --- /dev/null +++ b/tests/Composer/Test/Util/NoProxyPatternTest.php @@ -0,0 +1,143 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Util; + +use Composer\Util\NoProxyPattern; +use PHPUnit\Framework\TestCase; + +class NoProxyPatternTest extends TestCase +{ + /** + * @dataProvider dataHostName + */ + public function testHostName($noproxy, $url, $expected) + { + $matcher = new NoProxyPattern($noproxy); + $url = $this->getUrl($url); + $this->assertEquals($expected, $matcher->test($url)); + } + + public function dataHostName() + { + $noproxy = 'foobar.com, .barbaz.net'; + + // noproxy, url, expected + return array( + 'match as foobar.com' => array($noproxy, 'foobar.com', true), + 'match foobar.com' => array($noproxy, 'www.foobar.com', true), + 'no match foobar.com' => array($noproxy, 'foofoobar.com', false), + 'match .barbaz.net 1' => array($noproxy, 'barbaz.net', true), + 'match .barbaz.net 2' => array($noproxy, 'www.barbaz.net', true), + 'no match .barbaz.net' => array($noproxy, 'barbarbaz.net', false), + 'no match wrong domain' => array($noproxy, 'barbaz.com', false), + 'no match FQDN' => array($noproxy, 'foobar.com.', false), + ); + } + + /** + * @dataProvider dataIpAddress + */ + public function testIpAddress($noproxy, $url, $expected) + { + $matcher = new NoProxyPattern($noproxy); + $url = $this->getUrl($url); + $this->assertEquals($expected, $matcher->test($url)); + } + + public function dataIpAddress() + { + $noproxy = '192.168.1.1, 2001:db8::52:0:1'; + + // noproxy, url, expected + return array( + 'match exact IPv4' => array($noproxy, '192.168.1.1', true), + 'no match IPv4' => array($noproxy, '192.168.1.4', false), + 'match exact IPv6' => array($noproxy, '[2001:db8:0:0:0:52:0:1]', true), + 'no match IPv6' => array($noproxy, '[2001:db8:0:0:0:52:0:2]', false), + 'match mapped IPv4' => array($noproxy, '[::FFFF:C0A8:0101]', true), + 'no match mapped IPv4' => array($noproxy, '[::FFFF:C0A8:0104]', false), + ); + } + + /** + * @dataProvider dataIpRange + */ + public function testIpRange($noproxy, $url, $expected) + { + $matcher = new NoProxyPattern($noproxy); + $url = $this->getUrl($url); + $this->assertEquals($expected, $matcher->test($url)); + } + + public function dataIpRange() + { + $noproxy = '10.0.0.0/30, 2002:db8:a::45/121'; + + // noproxy, url, expected + return array( + 'match IPv4/CIDR' => array($noproxy, '10.0.0.2', true), + 'no match IPv4/CIDR' => array($noproxy, '10.0.0.4', false), + 'match IPv6/CIDR' => array($noproxy, '[2002:db8:a:0:0:0:0:7f]', true), + 'no match IPv6' => array($noproxy, '[2002:db8:a:0:0:0:0:ff]', false), + 'match mapped IPv4' => array($noproxy, '[::FFFF:0A00:0002]', true), + 'no match mapped IPv4' => array($noproxy, '[::FFFF:0A00:0004]', false), + ); + } + + /** + * @dataProvider dataPort + */ + public function testPort($noproxy, $url, $expected) + { + $matcher = new NoProxyPattern($noproxy); + $url = $this->getUrl($url); + $this->assertEquals($expected, $matcher->test($url)); + } + + public function dataPort() + { + $noproxy = '192.168.1.2:81, 192.168.1.3:80, [2001:db8::52:0:2]:443, [2001:db8::52:0:3]:80'; + + // noproxy, url, expected + return array( + 'match IPv4 port' => array($noproxy, '192.168.1.3', true), + 'no match IPv4 port' => array($noproxy, '192.168.1.2', false), + 'match IPv6 port' => array($noproxy, '[2001:db8::52:0:3]', true), + 'no match IPv6 port' => array($noproxy, '[2001:db8::52:0:2]', false), + ); + } + + /** + * Appends a scheme to the test url if it is missing + * + * @param string $url + */ + private function getUrl($url) + { + if (parse_url($url, PHP_URL_SCHEME)) { + return $url; + } + + $scheme = 'http'; + + if (strpos($url, '[') !== 0 && strrpos($url, ':') !== false) { + list(, $port) = explode(':', $url); + + if ($port === '443') { + $scheme = 'https'; + } + } + + return sprintf('%s://%s', $scheme, $url); + } +}