Merge pull request #8458 from johnstevenson/noproxy

Rewrite NoProxyPattern to include IPv6
main
Jordi Boggiano 5 years ago committed by GitHub
commit 082422f334
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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, 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);
}
}
Loading…
Cancel
Save