Merge pull request #6924 from JakubOnderka/cidr-tool

new: [internal] Cidr tool for faster checking CIDR ranges
pull/6926/head
Jakub Onderka 2021-01-29 09:18:33 +01:00 committed by GitHub
commit df9f1075d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 166 additions and 105 deletions

View File

@ -212,6 +212,7 @@ jobs:
./app/Vendor/bin/parallel-lint --exclude app/Lib/cakephp/ --exclude app/Vendor/ --exclude app/Lib/random_compat/ -e php,ctp app/
./app/Vendor/bin/phpunit app/Test/ComplexTypeToolTest.php
./app/Vendor/bin/phpunit app/Test/JSONConverterToolTest.php
./app/Vendor/bin/phpunit app/Test/CidrToolTest.php
# Ensure the perms of config files
sudo chown -R $USER:www-data `pwd`/app/Config

120
app/Lib/Tools/CidrTool.php Normal file
View File

@ -0,0 +1,120 @@
<?php
class CidrTool
{
/** @var array */
private $ipv4 = [];
/** @var array */
private $ipv6 = [];
public function __construct(array $list)
{
$this->filterInputList($list);
}
/**
* @param string $value IPv4 or IPv6 address or range
* @return false|string
*/
public function contains($value)
{
$valueMask = null;
if (strpos($value, '/') !== false) {
list($value, $valueMask) = explode('/', $value);
}
$match = false;
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
// This code converts IP address to all possible CIDRs that can contains given IP address
// and then check if given hash table contains that CIDR.
$ip = ip2long($value);
// Start from 1, because doesn't make sense to check 0.0.0.0/0 match
for ($bits = 1; $bits <= 32; $bits++) {
$mask = -1 << (32 - $bits);
$needle = long2ip($ip & $mask) . "/$bits";
if (isset($this->ipv4[$needle])) {
$match = $needle;
break;
}
}
} elseif (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$value = unpack('n*', inet_pton($value));
foreach ($this->ipv6 as $lv) {
if ($this->ipv6InCidr($value, $lv)) {
$match = $lv;
break;
}
}
}
if ($match && $valueMask) {
$matchMask = explode('/', $match)[1];
if ($valueMask < $matchMask) {
return false;
}
}
return $match;
}
/**
* Using solution from https://github.com/symfony/symfony/blob/master/src/Symfony/Component/HttpFoundation/IpUtils.php
*
* @param array $ip
* @param string $cidr
* @return bool
*/
private function ipv6InCidr($ip, $cidr)
{
list($address, $netmask) = explode('/', $cidr);
$bytesAddr = unpack('n*', inet_pton($address));
for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) {
$left = $netmask - 16 * ($i - 1);
$left = ($left <= 16) ? $left : 16;
$mask = ~(0xffff >> $left) & 0xffff;
if (($bytesAddr[$i] & $mask) != ($ip[$i] & $mask)) {
return false;
}
}
return true;
}
/**
* Filter out invalid IPv4 or IPv4 CIDR and append maximum netmask if no netmask is given.
* @param array $list
*/
private function filterInputList(array $list)
{
foreach ($list as $v) {
$parts = explode('/', $v, 2);
if (filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$maximumNetmask = 32;
} else if (filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$parts[0] = strtolower($parts[0]);
$maximumNetmask = 128;
} else {
// IP address part of CIDR is invalid
continue;
}
if (!isset($parts[1])) {
// If CIDR doesnt contains '/', we will consider CIDR as /32 for IPv4 or /128 for IPv6
$v = "$v/$maximumNetmask";
} else if ($parts[1] > $maximumNetmask || $parts[1] < 0) {
// Netmask part of CIDR is invalid
continue;
}
if ($maximumNetmask === 32) {
$this->ipv4[$v] = true;
} else {
$this->ipv6[] = $v;
}
}
}
}

View File

@ -1,5 +1,6 @@
<?php
App::uses('AppModel', 'Model');
App::uses('CidrTool', 'Tools');
/**
* @property WarninglistType $WarninglistType
@ -398,39 +399,6 @@ class Warninglist extends AppModel
}
}
/**
* Filter out invalid IPv4 or IPv4 CIDR and append maximum netmaks if no netmask is given.
* @param array $inputValues
* @return array
*/
private function filterCidrList($inputValues)
{
$outputValues = [];
foreach ($inputValues as $v) {
$v = strtolower($v);
$parts = explode('/', $v, 2);
if (filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$maximumNetmask = 32;
} else if (filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$maximumNetmask = 128;
} else {
// IP address part of CIDR is invalid
continue;
}
if (!isset($parts[1])) {
// If CIDR doesnt contains '/', we will consider CIDR as /32 for IPv4 or /128 for IPv6
$v = "$v/$maximumNetmask";
} else if ($parts[1] > $maximumNetmask || $parts[1] < 0) {
// Netmask part of CIDR is invalid
continue;
}
$outputValues[$v] = true;
}
return $outputValues;
}
/**
* For 'hostname', 'string' and 'cidr' warninglist type, values are just in keys to save memory.
*
@ -459,7 +427,7 @@ class Warninglist extends AppModel
}
$values = $output;
} else if ($warninglist['Warninglist']['type'] === 'cidr') {
$values = $this->filterCidrList($values);
$values = new CidrTool($values);
}
$this->entriesCache[$id] = $values;
@ -497,7 +465,7 @@ class Warninglist extends AppModel
}
/**
* @param array $listValues
* @param array|CidrTool $listValues
* @param string $value
* @param string $type
* @param string $listType
@ -512,7 +480,7 @@ class Warninglist extends AppModel
}
foreach ($value as $v) {
if ($listType === 'cidr') {
$result = $this->__evalCidrList($listValues, $v);
$result = $listValues->contains($v);
} elseif ($listType === 'string') {
$result = $this->__evalString($listValues, $v);
} elseif ($listType === 'substring') {
@ -536,75 +504,6 @@ class Warninglist extends AppModel
return $this->__checkValue($listValues, $value, '', $type) !== false;
}
private function __evalCidrList($listValues, $value)
{
$valueMask = null;
if (strpos($value, '/') !== false) {
list($value, $valueMask) = explode('/', $value);
}
$match = false;
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
// This code converts IP address to all possible CIDRs that can contains given IP address
// and then check if given hash table contains that CIDR.
$ip = ip2long($value);
// Start from 1, because doesn't make sense to check 0.0.0.0/0 match
for ($bits = 1; $bits <= 32; $bits++) {
$mask = -1 << (32 - $bits);
$needle = long2ip($ip & $mask) . "/$bits";
if (isset($listValues[$needle])) {
$match = $needle;
break;
}
}
} elseif (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
foreach ($listValues as $lv => $foo) {
if (strpos($lv, ':') !== false) { // Filter out IPv4 CIDR, IPv6 CIDR must contain colon
if ($this->__ipv6InCidr($value, $lv)) {
$match = $lv;
break;
}
}
}
}
if ($match && $valueMask) {
$matchMask = explode('/', $match)[1];
if ($valueMask < $matchMask) {
return false;
}
}
return $match;
}
/**
* Using solution from https://github.com/symfony/symfony/blob/master/src/Symfony/Component/HttpFoundation/IpUtils.php
*
* @param string $ip
* @param string $cidr
* @return bool
*/
private function __ipv6InCidr($ip, $cidr)
{
list($address, $netmask) = explode('/', $cidr);
$bytesAddr = unpack('n*', inet_pton($address));
$bytesTest = unpack('n*', inet_pton($ip));
for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) {
$left = $netmask - 16 * ($i - 1);
$left = ($left <= 16) ? $left : 16;
$mask = ~(0xffff >> $left) & 0xffff;
if (($bytesAddr[$i] & $mask) != ($bytesTest[$i] & $mask)) {
return false;
}
}
return true;
}
/**
* Check for exact match.
*

41
app/Test/CidrToolTest.php Normal file
View File

@ -0,0 +1,41 @@
<?php
require_once __DIR__ . '/../Lib/Tools/CidrTool.php';
use PHPUnit\Framework\TestCase;
class CidrToolTest extends TestCase
{
public function testEmptyList(): void
{
$cidrTool = new CidrTool([]);
$this->assertFalse($cidrTool->contains('1.2.3.4'));
}
public function testIpv4Fullmask(): void
{
$cidrTool = new CidrTool(['1.2.3.4/32']);
$this->assertEquals('1.2.3.4/32', $cidrTool->contains('1.2.3.4'));
}
public function testIpv4WithoutNetmask(): void
{
$cidrTool = new CidrTool(['1.2.3.4']);
$this->assertEquals('1.2.3.4/32', $cidrTool->contains('1.2.3.4'));
}
public function testIpv4(): void
{
$cidrTool = new CidrTool(['10.0.0.0/8', '8.0.0.0/8', '9.0.0.0/8']);
$this->assertEquals('8.0.0.0/8', $cidrTool->contains('8.8.8.8'));
$this->assertFalse($cidrTool->contains('::1'));
$this->assertFalse($cidrTool->contains('7.1.2.3'));
}
public function testIpv6(): void
{
$cidrTool = new CidrTool(['2001:0db8:1234::/48']);
$this->assertEquals('2001:0db8:1234::/48', $cidrTool->contains('2001:0db8:1234:0000:0000:0000:0000:0000'));
$this->assertEquals('2001:0db8:1234::/48', $cidrTool->contains('2001:0db8:1234:ffff:ffff:ffff:ffff:ffff'));
$this->assertFalse($cidrTool->contains('2002:0db8:1234:ffff:ffff:ffff:ffff:ffff'));
}
}