Bläddra i källkod

Merge pull request #8458 from johnstevenson/noproxy

Rewrite NoProxyPattern to include IPv6
Jordi Boggiano 6 år sedan
förälder
incheckning
082422f334
2 ändrade filer med 503 tillägg och 72 borttagningar
  1. 360 72
      src/Composer/Util/NoProxyPattern.php
  2. 143 0
      tests/Composer/Test/Util/NoProxyPatternTest.php

+ 360 - 72
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);
     }
 }

+ 143 - 0
tests/Composer/Test/Util/NoProxyPatternTest.php

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