Rewrite NoProxyPattern to include IPv6

This includes two breaking changes:
- the hostname is not resolved in the case of an IP address.
- a hostname with a trailing period (FQDN) is not matched.

This brings the basic implementation in line with curl behaviour, with
the addition of full IP address and range matching (curl does not
differentiate between IP addresses host names).

The NO_PROXY environment variable can be set to either a comma-separated
list of host names that should not use a proxy, or single asterisk `*`
to match all hosts.

- Port numbers can be included by prefixing the port with a colon `:`.
- IP addresses can be used, but must be enclosed in square brackets
`[...]` if they include a port number.
- IP address ranges can specified in CIDR notation, separating the IP
address and prefix-length with a forward slash `/`.
main
johnstevenson 5 years ago
parent 67e170eaa8
commit 74ba9decdf

@ -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);
}
}

@ -0,0 +1,143 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* 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, 192.168.1.2, 192.168.1.3, 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/64';
// 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:123]', true),
'no match IPv6' => array($noproxy, '[2001:db8::52:0:2]', 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);
}
}
Loading…
Cancel
Save