diff --git a/CHANGELOG b/CHANGELOG index c472757..791e1ef 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -157,6 +157,15 @@ are validated (no stream wrappers like phar://, a .mmdb file name) - Added SECURITY.md with the supported versions and how to report vulnerabilities + - Security: the check of each request against the block and allow list is + a lookup by index. It used to read all networks of both lists and test + each in PHP, for every request of every visitor: 65-70 ms with 170000 + entries, now 1-2 ms. Addresses and networks are stored in a canonical + form (lower case, compressed, networks as their network address, like + "2001:db8:1::/48"); the component's forms do that, and the plugin + converts existing entries once, on the first request after the update. + Entries which are added to the tables by other means have to be in that + form as well - Security: the bundled MaxMind DB reader (maxmind-db/reader, now 1.14.0, which limits the decoding of malformed or hostile database files) is tracked in composer.json/composer.lock; src/Helper/Geo is generated from diff --git a/sql/install.mysql.utf8.sql b/sql/install.mysql.utf8.sql index b005fb0..5385a03 100644 --- a/sql/install.mysql.utf8.sql +++ b/sql/install.mysql.utf8.sql @@ -21,7 +21,10 @@ CREATE TABLE IF NOT EXISTS #__bfstop_bannedip ( -- latest one (issue #219); NULL = no attempt since the block started attempts int unsigned NOT NULL DEFAULT 0, last_attempt datetime NULL DEFAULT NULL, - PRIMARY KEY (id) + PRIMARY KEY (id), +-- looked up for every request: addresses and networks are stored in +-- canonical form (IpHelper::canonicalEntry()) + KEY ipaddress (ipaddress) ) DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci; @@ -56,7 +59,8 @@ CREATE TABLE IF NOT EXISTS #__bfstop_allowlist ( id int(10) NOT NULL auto_increment, ipaddress varchar(45) NOT NULL, notes varchar(255) NOT NULL DEFAULT '', - PRIMARY KEY (id) + PRIMARY KEY (id), + KEY ipaddress (ipaddress) ) DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci; diff --git a/sql/install.postgresql.utf8.sql b/sql/install.postgresql.utf8.sql index f5adb44..5c05453 100644 --- a/sql/install.postgresql.utf8.sql +++ b/sql/install.postgresql.utf8.sql @@ -30,6 +30,9 @@ CREATE TABLE IF NOT EXISTS "#__bfstop_bannedip" ( "last_attempt" timestamp without time zone DEFAULT NULL, PRIMARY KEY ("id") ); +-- looked up for every request: addresses and networks are stored in canonical +-- form (IpHelper::canonicalEntry()) +CREATE INDEX IF NOT EXISTS "#__bfstop_bannedip_ipaddress" ON "#__bfstop_bannedip" ("ipaddress"); CREATE TABLE IF NOT EXISTS "#__bfstop_unblock" ( @@ -56,6 +59,7 @@ CREATE TABLE IF NOT EXISTS "#__bfstop_allowlist" ( "notes" varchar(255) NOT NULL DEFAULT '', PRIMARY KEY ("id") ); +CREATE INDEX IF NOT EXISTS "#__bfstop_allowlist_ipaddress" ON "#__bfstop_allowlist" ("ipaddress"); CREATE TABLE IF NOT EXISTS "#__bfstop_knownip" ( diff --git a/sql/updates/2.0.0.sql b/sql/updates/2.0.0.sql index a076ba0..1ba4186 100755 --- a/sql/updates/2.0.0.sql +++ b/sql/updates/2.0.0.sql @@ -61,3 +61,9 @@ INSERT INTO `#__bfstop_username_stats` (username, attempts, first_attempt, last_ -- speeds up the per-username (account-level) throttle query ALTER TABLE `#__bfstop_failedlogin` ADD INDEX username_logtime (username, logtime); + +-- every request looks up the visitor's address in the block and allow list; +-- addresses and networks are stored in canonical form (the plugin brings +-- existing entries into it once, IpHelper::canonicalEntry()) +ALTER TABLE `#__bfstop_bannedip` ADD INDEX ipaddress (ipaddress); +ALTER TABLE `#__bfstop_allowlist` ADD INDEX ipaddress (ipaddress); diff --git a/src/Extension/Bfstop.php b/src/Extension/Bfstop.php index e43858e..7fbc971 100644 --- a/src/Extension/Bfstop.php +++ b/src/Extension/Bfstop.php @@ -717,6 +717,17 @@ public function onAfterInitialise($event) { return; } + // lookups of the block and allow list need the addresses in canonical + // form, which entries of earlier versions are not in: once, before the + // first lookup of this version (every request has one, later). The + // installer script can't do this reliably - the class of the previous + // version may be loaded still + if (!$this->getBoolParam('addressesCanonical', false)) + { + $this->mydb->canonicalizeStoredAddresses(); + $this->mydb->savePluginSetting('addressesCanonical', 1); + $this->params->set('addressesCanonical', 1); + } // periodic maintenance, at most once a day $purgeInterval = 86400; // = 24*60*60 => one day $lastPurge = $this->params->get('lastPurge', 0); diff --git a/src/Helper/DatabaseHelper.php b/src/Helper/DatabaseHelper.php index 231026f..3f75079 100644 --- a/src/Helper/DatabaseHelper.php +++ b/src/Helper/DatabaseHelper.php @@ -206,37 +206,36 @@ public function getFormattedFailedList($ipAddress, $curTime, $interval) /** * Loads the rows of $table (aliased t, needs id and ipaddress columns) - * which match the given IP address, either literally or as part of a - * stored IPv4/IPv6 subnet (CIDR notation). Only the literal match is done - * in SQL; subnet entries are matched in PHP (IpHelper::isInSubnet), which - * works the same on every database - the previous SQL implementation - * relied on MySQL-only functions (INET_ATON, INET6_ATON, LOCATE, REGEXP, - * ...), see issue #206, and was the source of #117, #134 and #142. + * which contain the given IP address: the address itself or a network + * (CIDR notation) it is part of. Addresses and networks are stored in + * canonical form (IpHelper::canonicalEntry()), so the entries which can + * match are known beforehand (IpHelper::lookupCandidates()), and the query + * is a lookup by index. It used to read every network of the table and test + * each in PHP, on every request of every visitor - slow once a few + * thousand IPv6 clients were blocked, and a way for them to slow the site + * down (the SQL was MySQL-only at first, see issue #206, and the source + * of #117, #134 and #142). */ private function loadMatchingEntries($ipaddress, $table, $additionalWhere, $action) { + $candidates = IpHelper::lookupCandidates($ipaddress); + if (count($candidates) === 0) + { + return array(); + } try { - // LOWER: IPv6 addresses may be stored in upper case; MySQL's - // default collations compare case-insensitively, PostgreSQL doesn't - $sql = "SELECT t.id, t.ipaddress FROM ".$table." t WHERE ". - "((t.ipaddress NOT LIKE '%/%' AND LOWER(t.ipaddress) = LOWER(".$this->db->quote($ipaddress)."))". - " OR t.ipaddress LIKE '%/%')". + $sql = "SELECT t.id, t.ipaddress FROM ".$table." t WHERE t.ipaddress IN (". + implode(', ', array_map(array($this->db, 'quote'), $candidates)).")". $additionalWhere; $this->db->setQuery($sql); - $entries = array(); - foreach ($this->db->loadObjectList() as $entry) + $entries = $this->db->loadObjectList(); + foreach ($entries as $entry) { - if (strpos($entry->ipaddress, '/') !== false && - !IpHelper::isInSubnet($ipaddress, $entry->ipaddress)) - { - continue; - } $this->logger->log($action." because of entry: ". "id=".$entry->id.", ". "ipaddress=".$entry->ipaddress, Log::DEBUG); - $entries[] = $entry; } return $entries; } @@ -247,6 +246,57 @@ private function loadMatchingEntries($ipaddress, $table, $additionalWhere, $acti } } + /** + * Brings the addresses in the block and allow list into canonical form + * (IpHelper::canonicalEntry()), which lookups depend on: entries written by + * earlier versions, or by hand, may be spelled differently ("2001:DB8::1", + * "203.0.113.5/24"). Entries which are no address or network are left alone. + * + * @return int the number of entries changed + */ + public function canonicalizeStoredAddresses() + { + $changed = 0; + try + { + foreach (array('#__bfstop_bannedip', '#__bfstop_allowlist') as $table) + { + $lastId = 0; + do + { + $query = $this->db->getQuery(true) + ->select($this->db->quoteName(array('id', 'ipaddress'))) + ->from($this->db->quoteName($table)) + ->where($this->db->quoteName('id').' > '.(int) $lastId) + ->order($this->db->quoteName('id').' ASC'); + $this->db->setQuery($query, 0, 5000); + $rows = $this->db->loadObjectList(); + foreach ($rows as $row) + { + $lastId = (int) $row->id; + $canonical = IpHelper::canonicalEntry($row->ipaddress); + if ($canonical !== null && $canonical !== $row->ipaddress) + { + $update = $this->db->getQuery(true) + ->update($this->db->quoteName($table)) + ->set($this->db->quoteName('ipaddress').' = '.$this->db->quote($canonical)) + ->where($this->db->quoteName('id').' = '.(int) $row->id); + $this->db->setQuery($update); + $this->db->execute(); + ++$changed; + } + } + } + while (count($rows) === 5000); + } + } + catch (\Exception $e) + { + $this->logger->log("Database exception occured: ".$e->getMessage(), Log::ERROR); + } + return $changed; + } + /** * IDs of all currently active blocks matching the given IP address, * whether as single address or as part of a blocked IPv4/IPv6 subnet. @@ -330,7 +380,7 @@ public function blockIP($logEntry, $duration, $usehtaccess, $htaccessPath) try { $blockEntry = new \stdClass(); - $blockEntry->ipaddress = $logEntry->ipaddress; + $blockEntry->ipaddress = IpHelper::canonicalEntry($logEntry->ipaddress) ?? $logEntry->ipaddress; $blockEntry->crdate = date("Y-m-d H:i:s"); $blockEntry->duration = $duration; if (!$this->db->insertObject('#__bfstop_bannedip', $blockEntry, 'id')) @@ -854,13 +904,22 @@ public function purgeOldEntries($purgeAgeWeeks) /** * Remembers when the periodic maintenance last ran. + */ + public function saveLastPurge($timestamp) + { + $this->savePluginSetting('lastPurge', (int) $timestamp); + } + + /** + * Stores one value in the plugin's settings (bookkeeping of the plugin, + * not set on the settings page). * * Only this one value is changed in the plugin's stored settings, which * are read from the database right now: writing back the settings the * request was started with would undo whatever an administrator saved in * the meantime. */ - public function saveLastPurge($timestamp) + public function savePluginSetting($name, $value) { try { @@ -880,7 +939,7 @@ public function saveLastPurge($timestamp) { $params = array(); } - $params['lastPurge'] = (int) $timestamp; + $params[$name] = $value; // no table alias here: PostgreSQL doesn't allow qualified column // names in the SET clause (issue #206) $query = $where($this->db->getQuery(true) diff --git a/src/Helper/IpHelper.php b/src/Helper/IpHelper.php index 61e1976..efca039 100644 --- a/src/Helper/IpHelper.php +++ b/src/Helper/IpHelper.php @@ -93,14 +93,65 @@ public static function isValidIpOrSubnet($value) (int) $parts[1] <= (strpos($parts[0], ':') !== false ? 128 : 32); } - private static function prefixMask($bits) + private static function prefixMask($bits, $length = 16) { $mask = str_repeat("\xff", intdiv($bits, 8)); if ($bits % 8 > 0) { $mask .= chr((0xff << (8 - $bits % 8)) & 0xff); } - return str_pad($mask, 16, "\x00"); + return str_pad($mask, $length, "\x00"); + } + + /** + * The one spelling in which addresses and networks are stored in the + * block and allow list: an address in the compressed lower-case form of + * inet_ntop(), a network as its masked network address and prefix length + * ("2001:db8:1:2::/64", "203.0.113.0/24"). Lookups compare these strings + * (see lookupCandidates()), so every writer has to use this form. + * + * @return string|null null if $value is neither an address nor a network + */ + public static function canonicalEntry($value) + { + $value = trim((string) $value); + if (!self::isValidIpOrSubnet($value)) + { + return null; + } + $parts = explode('/', $value); + $bin = inet_pton($parts[0]); + if (count($parts) === 1) + { + return inet_ntop($bin); + } + $bits = (int) $parts[1]; + return inet_ntop($bin & self::prefixMask($bits, strlen($bin))).'/'.$bits; + } + + /** + * All the entries (in canonical form) of the block or allow list which + * contain $ip: the address itself and its network for every prefix length. + * Fetching the rows whose address is one of these finds what the address is + * blocked or allowed by, with an index and without looking at the other + * entries - how many there are depends on what visitors made the plugin + * block, and a request from any visitor has to look. + * + * @return string[] empty if $ip is not an address + */ + public static function lookupCandidates($ip) + { + $bin = @inet_pton(trim((string) $ip)); + if ($bin === false) + { + return array(); + } + $candidates = array(inet_ntop($bin)); + for ($bits = 0; $bits <= strlen($bin) * 8; ++$bits) + { + $candidates[] = inet_ntop($bin & self::prefixMask($bits, strlen($bin))).'/'.$bits; + } + return $candidates; } /** @@ -125,7 +176,9 @@ public static function trackedAddress($ip, $prefixLength) substr($bin, 0, 2) === "\x20\x02" || // 6to4, 2002::/16 substr($bin, 0, 4) === "\x20\x01\x00\x00") // Teredo, 2001::/32 { - return $ip; + // in the form the block list holds it in, whatever way the web + // server spells the address (garbage is returned as it is) + return self::canonicalEntry($ip) ?? $ip; } return inet_ntop($bin & self::prefixMask($prefixLength)).'/'.$prefixLength; } diff --git a/tests/Integration/ComponentTest.php b/tests/Integration/ComponentTest.php index 74e5f03..2b7556e 100644 --- a/tests/Integration/ComponentTest.php +++ b/tests/Integration/ComponentTest.php @@ -422,4 +422,16 @@ public function testEditViewsRegisterTheirStylesheetsAsRealFiles() $this->assertStringEndsWith('/edit.css', $uri, $view); } } + + public function testEntriesOfTheEditFormsAreStoredInCanonicalForm() + { + $blockId = $this->saveNew(BlockModel::class, + array('ipaddress' => '203.0.113.77/24', 'crdate' => '2026-01-02', 'duration' => '0')); + $allowId = $this->saveNew(AllowModel::class, array('ipaddress' => '2001:DB8::ABCD', 'notes' => '')); + $this->assertSame('203.0.113.0/24', $this->queryValue('SELECT ipaddress FROM #__bfstop_bannedip WHERE id='.$blockId)); + $this->assertSame('2001:db8::abcd', $this->queryValue('SELECT ipaddress FROM #__bfstop_allowlist WHERE id='.$allowId)); + $helper = new DatabaseHelper($this->logger); + $this->assertTrue($helper->isIPBlocked('203.0.113.200')); + $this->assertTrue($helper->isIPOnAllowList('2001:db8::ABCD')); + } } diff --git a/tests/Integration/DatabaseHelperTest.php b/tests/Integration/DatabaseHelperTest.php index 9c4d777..b009e36 100644 --- a/tests/Integration/DatabaseHelperTest.php +++ b/tests/Integration/DatabaseHelperTest.php @@ -105,6 +105,9 @@ public function testActiveBlocks() $this->block('192.0.2.0/abc', 0, 0); $unblocked = $this->block('203.0.113.8', 0, 0); $this->insert('#__bfstop_unblock', array('block_id' => $unblocked, 'source' => 0, 'crdate' => self::minutesAgo(0))); + // entries of earlier versions (and typed by hand) are not in the canonical + // form the lookup relies on: the upper case ones are the two IPv6 entries + $this->assertSame(2, $this->helper->canonicalizeStoredAddresses()); $this->assertSame(array($exact, $net4small), $this->activeBlocks('203.0.113.5')); $this->assertSame(array(), $this->activeBlocks('203.0.113.6'), 'expired'); @@ -156,6 +159,7 @@ public function testAllowList() $this->insert('#__bfstop_allowlist', array('ipaddress' => '10.1.0.0/16', 'notes' => '')); $this->insert('#__bfstop_allowlist', array('ipaddress' => '2001:DB8:FFFF::1', 'notes' => '')); $this->insert('#__bfstop_allowlist', array('ipaddress' => '198.51.100.3', 'notes' => '')); + $this->assertSame(1, $this->helper->canonicalizeStoredAddresses()); $this->assertTrue($this->helper->isIPOnAllowList('10.1.2.3')); $this->assertFalse($this->helper->isIPOnAllowList('10.2.0.1')); @@ -406,4 +410,62 @@ public function testFailedLoginsAreLimited() $this->db->setQuery('SELECT username FROM #__bfstop_failedlogin ORDER BY id'); $this->assertSame(array('user7', 'user8', 'user9', 'user10', 'user11'), $this->db->loadColumn()); } + + public function testCanonicalizeStoredAddresses() + { + $unaligned = $this->block('203.0.113.77/24', 0, 60); + $upper = $this->block('2001:DB8::ABCD', 0, 60); + $fine = $this->block('198.51.100.9', 0, 60); + $garbage = $this->block('203.0.113.0/abc', 0, 60); + $allow = $this->insert('#__bfstop_allowlist', array('ipaddress' => '2001:0DB8:0:0:0:0:0:5/64', 'notes' => ''), 'id'); + + $this->assertSame(3, $this->helper->canonicalizeStoredAddresses()); + $stored = function ($table, $id) + { + return $this->queryValue('SELECT ipaddress FROM '.$table.' WHERE id='.(int) $id); + }; + $this->assertSame('203.0.113.0/24', $stored('#__bfstop_bannedip', $unaligned)); + $this->assertSame('2001:db8::abcd', $stored('#__bfstop_bannedip', $upper)); + $this->assertSame('198.51.100.9', $stored('#__bfstop_bannedip', $fine)); + $this->assertSame('203.0.113.0/abc', $stored('#__bfstop_bannedip', $garbage), 'what is no entry is left alone'); + $this->assertSame('2001:db8::/64', $stored('#__bfstop_allowlist', $allow)); + $this->assertSame(0, $this->helper->canonicalizeStoredAddresses(), 'nothing left to do'); + $this->assertTrue($this->helper->isIPOnAllowList('2001:db8:0:0:1::1')); + } + + public function testCanonicalizeStoredAddressesInBatches() + { + // more entries than one batch of the migration + $values = array(); + for ($i = 0; $i < 5100; ++$i) + { + $values[] = "('10.".intdiv($i, 250).'.'.($i % 250).".1/16', '".self::minutesAgo(0)."', 60)"; + } + $this->db->setQuery('INSERT INTO #__bfstop_bannedip (ipaddress, crdate, duration) VALUES '.implode(',', $values)); + $this->db->execute(); + $this->assertSame(5100, $this->helper->canonicalizeStoredAddresses()); + $this->assertSame(0, (int) $this->queryValue("SELECT COUNT(*) FROM #__bfstop_bannedip WHERE ipaddress LIKE '%.1/16'")); + $this->assertSame(0, $this->helper->canonicalizeStoredAddresses()); + } + + public function testBlocksAreStoredInCanonicalForm() + { + $this->helper->blockIP((object) array('ipaddress' => '2001:DB8::5', 'logtime' => self::minutesAgo(0), + 'username' => 'x', 'origin' => 0), 60, false, ''); + $this->assertSame('2001:db8::5', $this->queryValue('SELECT ipaddress FROM #__bfstop_bannedip')); + $this->assertTrue($this->helper->isIPBlocked('2001:db8::5')); + $this->assertTrue($this->helper->isIPBlocked('2001:DB8:0:0:0:0:0:5')); + } + + public function testLookupDoesNotLoadWhatCannotMatch() + { + // networks of the other address family, and of other prefixes, are no candidates + $this->block('2001:db8::/32', 0, 60); + $this->block('203.0.113.0/24', 0, 60); + $this->block('203.0.114.0/24', 0, 60); + $this->assertCount(1, $this->helper->getActiveBlockIds('203.0.113.9')); + $this->assertCount(1, $this->helper->getActiveBlockIds('2001:db8:5::1')); + $this->assertSame(array(), $this->helper->getActiveBlockIds('')); + $this->assertSame(array(), $this->helper->getActiveBlockIds('203.0.113.0/24'), 'a network is no client address'); + } } diff --git a/tests/Unit/IpHelperTest.php b/tests/Unit/IpHelperTest.php index 6c2d5e5..9c1abb8 100644 --- a/tests/Unit/IpHelperTest.php +++ b/tests/Unit/IpHelperTest.php @@ -166,7 +166,7 @@ public static function trackedAddressProvider() '0 means per address' => array('2001:db8:1:2::1', 0, '2001:db8:1:2::1'), 'absurdly short prefix' => array('2001:db8:1:2::1', 16, '2001:db8:1:2::1'), 'IPv4-mapped stays' => array('::ffff:192.0.2.7', 64, '::ffff:192.0.2.7'), - 'NAT64 stays' => array('64:ff9b::192.0.2.7', 64, '64:ff9b::192.0.2.7'), + 'NAT64 stays (canonical form)' => array('64:ff9b::192.0.2.7', 64, '64:ff9b::c000:207'), '6to4 stays' => array('2002:c000:207::1', 64, '2002:c000:207::1'), 'Teredo stays' => array('2001:0:4136:e378:8000:63bf:3fff:fdd2', 64, '2001:0:4136:e378:8000:63bf:3fff:fdd2'), 'unique local stays' => array('fd00::1', 64, 'fd00::1'), @@ -191,4 +191,88 @@ public function testTrackedNetworkContainsTheAddress() $this->assertFalse(IpHelper::isInSubnet('2001:db8:1:3::99', $tracked)); $this->assertTrue(IpHelper::isValidIpOrSubnet($tracked)); } + + public static function canonicalEntryProvider() + { + return array( + 'IPv4' => array('203.0.113.5', '203.0.113.5'), + 'IPv4 network' => array('203.0.113.0/24', '203.0.113.0/24'), + 'host bits are masked' => array('203.0.113.77/24', '203.0.113.0/24'), + 'not on a byte border' => array('203.0.113.77/27', '203.0.113.64/27'), + '/32 stays a network' => array('203.0.113.5/32', '203.0.113.5/32'), + 'everything' => array('1.2.3.4/0', '0.0.0.0/0'), + 'IPv6 in upper case' => array('2001:DB8::1', '2001:db8::1'), + 'IPv6 written out' => array('2001:0db8:0000:0000:0000:0000:0000:0001', '2001:db8::1'), + 'IPv6 network' => array('2001:DB8:AB:1:2:3:4:5/48', '2001:db8:ab::/48'), + 'IPv6 not on a border' => array('2001:db8:1:ff7f::1/56', '2001:db8:1:ff00::/56'), + 'IPv4-mapped' => array('::FFFF:1.2.3.4', '::ffff:1.2.3.4'), + 'surrounding space' => array(' 203.0.113.5 ', '203.0.113.5'), + 'prefix too long' => array('203.0.113.0/33', null), + 'prefix too long (IPv6)' => array('2001:db8::/129', null), + 'prefix is no number' => array('203.0.113.0/abc', null), + 'prefix is hexadecimal' => array('203.0.113.0/0x8', null), + 'no address' => array('not-an-ip', null), + 'empty' => array('', null), + 'two slashes' => array('203.0.113.0/24/1', null), + ); + } + + #[DataProvider('canonicalEntryProvider')] + public function testCanonicalEntry($value, $expected) + { + $this->assertSame($expected, IpHelper::canonicalEntry($value)); + if ($expected !== null) + { + $this->assertSame($expected, IpHelper::canonicalEntry($expected), 'stable'); + } + } + + public function testLookupCandidatesIPv4() + { + $candidates = IpHelper::lookupCandidates('203.0.113.77'); + $this->assertCount(34, $candidates); + $this->assertSame('203.0.113.77', $candidates[0]); + foreach (array('203.0.113.77/32', '203.0.113.64/27', '203.0.113.0/24', '203.0.112.0/23', '0.0.0.0/0') as $network) + { + $this->assertContains($network, $candidates); + } + $this->assertContains('203.0.113.0/25', $candidates); + $this->assertNotContains('203.0.113.128/25', $candidates); + } + + public function testLookupCandidatesIPv6() + { + $candidates = IpHelper::lookupCandidates('2001:DB8:1:2::99'); + $this->assertCount(130, $candidates); + $this->assertSame('2001:db8:1:2::99', $candidates[0]); + foreach (array('2001:db8:1:2::99/128', '2001:db8:1:2::/64', '2001:db8:1::/48', '2000::/3', '::/0') as $network) + { + $this->assertContains($network, $candidates); + } + $this->assertNotContains('2001:db8:1:3::/64', $candidates); + } + + public function testLookupCandidatesOfNoAddress() + { + $this->assertSame(array(), IpHelper::lookupCandidates('')); + $this->assertSame(array(), IpHelper::lookupCandidates('203.0.113.0/24')); + $this->assertSame(array(), IpHelper::lookupCandidates('not-an-ip')); + } + + public function testEveryCandidateIsACanonicalEntryContainingTheAddress() + { + // the lookup is by string comparison: what it looks for has to be + // what canonicalEntry() writes, and has to contain the address + foreach (array('203.0.113.77', '2001:db8:1:2:aaaa:bbbb:cccc:dddd', '::ffff:10.0.0.1', '::1') as $ip) + { + foreach (IpHelper::lookupCandidates($ip) as $candidate) + { + $this->assertSame($candidate, IpHelper::canonicalEntry($candidate), $candidate); + if (strpos($candidate, '/') !== false) + { + $this->assertTrue(IpHelper::isInSubnet($ip, $candidate), "$candidate contains $ip"); + } + } + } + } }