Skip to content

Commit

Permalink
Add IpRanges object (#63)
Browse files Browse the repository at this point in the history
Co-authored-by: Alexander Makarov <sam@rmcreative.ru>
  • Loading branch information
vjik and samdark authored Aug 6, 2024
1 parent cf739a9 commit 5b9f1e4
Show file tree
Hide file tree
Showing 4 changed files with 382 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 1.0.2 under development

- New #63: Add `IpRanges` that represents a set of IP ranges that are either allowed or forbidden (@vjik)
- Bug #59: Fix error while converting IP address to bits representation in PHP 8.0+ (@vjik)

## 1.0.1 January 27, 2022
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ if (!DnsHelper::existsA('yiiframework.com')) {
}
```

### `IpRanges`

```php
use Yiisoft\NetworkUtilities\IpRanges;

$ipRanges = new IpRanges(
[
'10.0.1.0/24',
'2001:db0:1:2::/64',
IpRanges::LOCALHOST,
'myNetworkEu',
'!' . IpRanges::ANY,
],
[
'myNetworkEu' => ['1.2.3.4/10', '5.6.7.8'],
],
);

$ipRanges->isAllowed('10.0.1.28/28'); // true
$ipRanges->isAllowed('1.2.3.4'); // true
$ipRanges->isAllowed('192.168.0.1'); // false
```

## Documentation

- [Internals](docs/internals.md)
Expand Down
179 changes: 179 additions & 0 deletions src/IpRanges.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

namespace Yiisoft\NetworkUtilities;

use InvalidArgumentException;

use function array_key_exists;
use function array_merge;
use function array_unique;
use function strlen;
use function strpos;

/**
* `IpRanges` represents a set of IP ranges that are either allowed or forbidden.
*/
final class IpRanges
{
public const ANY = 'any';
public const PRIVATE = 'private';
public const MULTICAST = 'multicast';
public const LINK_LOCAL = 'linklocal';
public const LOCALHOST = 'localhost';
public const DOCUMENTATION = 'documentation';
public const SYSTEM = 'system';

/**
* Default network aliases.
* @see https://datatracker.ietf.org/doc/html/rfc5735#section-4
*/
public const DEFAULT_NETWORKS = [
'*' => [self::ANY],
self::ANY => ['0.0.0.0/0', '::/0'],
self::PRIVATE => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
self::MULTICAST => ['224.0.0.0/4', 'ff00::/8'],
self::LINK_LOCAL => ['169.254.0.0/16', 'fe80::/10'],
self::LOCALHOST => ['127.0.0.0/8', '::1'],
self::DOCUMENTATION => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
self::SYSTEM => [self::MULTICAST, self::LINK_LOCAL, self::LOCALHOST, self::DOCUMENTATION],
];

/**
* @var string[]
*/
private array $ranges;

/**
* @psalm-var array<string, list<string>>
*/
private array $networks;

/**
* @param string[] $ranges The IPv4 or IPv6 ranges that are either allowed or forbidden.
*
* The following preparation tasks are performed:
* - recursively substitute aliases (described in {@see $networks}) with their values;
* - remove duplicates.
*
* When the array is empty or the option is not set, all IP addresses are allowed.
*
* Otherwise, the rules are checked sequentially until the first match is found. An IP address is forbidden
* when it hasn't matched any of the rules.
*
* Example:
*
* ```php
* new Ip(ranges: [
* '192.168.10.128'
* '!192.168.10.0/24',
* 'any' // allows any other IP addresses
* ]);
* ```
*
* In this example, access is allowed for all the IPv4 and IPv6 addresses excluding the `192.168.10.0/24`
* subnet. IPv4 address `192.168.10.128` is also allowed, because it is listed before the restriction.
* @param array $networks Custom network aliases, that can be used in {@see $ranges}:
* - key - alias name;
* - value - array of strings. String can be an IP range, IP address or another alias. String can be negated
* with `!` character.
* The default aliases are defined in {@see self::DEFAULT_NETWORKS} and will be merged with custom ones.
*
* @psalm-param array<string, list<string>> $networks
*/
public function __construct(array $ranges = [], array $networks = [])
{
foreach ($networks as $key => $_values) {
if (array_key_exists($key, self::DEFAULT_NETWORKS)) {
throw new InvalidArgumentException("Network alias \"{$key}\" already set as default.");
}
}
$this->networks = array_merge(self::DEFAULT_NETWORKS, $networks);

$this->ranges = $this->prepareRanges($ranges);
}

/**
* Get the IPv4 or IPv6 ranges that are either allowed or forbidden.
*
* @return string[] The IPv4 or IPv6 ranges that are either allowed or forbidden.
*/
public function getRanges(): array
{
return $this->ranges;
}

/**
* Get network aliases, that can be used in {@see $ranges}.
*
* @return array Network aliases.
*
* @see $networks
*/
public function getNetworks(): array
{
return $this->networks;
}

/**
* Whether the IP address with specified CIDR is allowed according to the {@see $ranges} list.
*/
public function isAllowed(string $ip): bool
{
if (empty($this->ranges)) {
return true;
}

foreach ($this->ranges as $string) {
[$isNegated, $range] = $this->parseNegatedRange($string);
if (IpHelper::inRange($ip, $range)) {
return !$isNegated;
}
}

return false;
}

/**
* Prepares array to fill in {@see $ranges}:
* - recursively substitutes aliases, described in `$networks` argument with their values;
* - removes duplicates.
*
* @param string[] $ranges
* @return string[]
*/
private function prepareRanges(array $ranges): array
{
$result = [];
foreach ($ranges as $string) {
[$isRangeNegated, $range] = $this->parseNegatedRange($string);
if (isset($this->networks[$range])) {
$replacements = $this->prepareRanges($this->networks[$range]);
foreach ($replacements as &$replacement) {
[$isReplacementNegated, $replacement] = $this->parseNegatedRange($replacement);
$result[] = ($isRangeNegated && !$isReplacementNegated ? '!' : '') . $replacement;
}
} else {
$result[] = $string;
}
}

return array_unique($result);
}

/**
* Parses IP address/range for the negation with `!`.
*
* @return array The result array consists of 2 elements:
* - `boolean` - whether the string is negated;
* - `string` - the string without negation (when the negation were present).
*
* @psalm-return array{0: bool, 1: string}
*/
private function parseNegatedRange(string $string): array
{
$isNegated = strpos($string, '!') === 0;
return [$isNegated, $isNegated ? substr($string, strlen('!')) : $string];
}
}
179 changes: 179 additions & 0 deletions tests/IpRangesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

namespace Yiisoft\NetworkUtilities\Tests;

use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Yiisoft\NetworkUtilities\IpRanges;

final class IpRangesTest extends TestCase
{
public function testReadmeExample(): void
{
$ipRanges = new IpRanges(
[
'10.0.1.0/24',
'2001:db0:1:2::/64',
IpRanges::LOCALHOST,
'myNetworkEu',
'!' . IpRanges::ANY,
],
[
'myNetworkEu' => ['1.2.3.4/10', '5.6.7.8'],
],
);

$this->assertTrue($ipRanges->isAllowed('10.0.1.28/28'));
$this->assertTrue($ipRanges->isAllowed('1.2.3.4'));
$this->assertFalse($ipRanges->isAllowed('192.168.0.1'));
}

public function testNetworkAliasException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Network alias "*" already set as default');
new IpRanges(['*'], ['*' => ['wrong']]);
}

public static function dataGetNetworks(): array
{
return [
'default' => [
[],
[
'*' => ['any'],
'any' => ['0.0.0.0/0', '::/0'],
'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
'multicast' => ['224.0.0.0/4', 'ff00::/8'],
'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
'localhost' => ['127.0.0.0/8', '::1'],
'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
],
],
'custom' => [
['custom' => ['1.1.1.1/1', '2.2.2.2/2']],
[
'*' => ['any'],
'any' => ['0.0.0.0/0', '::/0'],
'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
'multicast' => ['224.0.0.0/4', 'ff00::/8'],
'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
'localhost' => ['127.0.0.0/8', '::1'],
'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
'custom' => ['1.1.1.1/1', '2.2.2.2/2'],
],
],
];
}

/**
* @dataProvider dataGetNetworks
*/
public function testGetNetworks(array $networks, array $expected): void
{
$ipRanges = new IpRanges([], $networks);
$this->assertSame($expected, $ipRanges->getNetworks());
}

public static function dataGetRange(): array
{
return [
'ipv4' => [['10.0.0.1'], ['10.0.0.1']],
'any' => [['192.168.0.32', 'fa::/32', 'any'], ['192.168.0.32', 'fa::/32', '0.0.0.0/0', '::/0']],
'ipv4+!private' => [
['10.0.0.1', '!private'],
['10.0.0.1', '!10.0.0.0/8', '!172.16.0.0/12', '!192.168.0.0/16', '!fd00::/8'],
],
'private+!system' => [
['private', '!system'],
[
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'fd00::/8',
'!224.0.0.0/4',
'!ff00::/8',
'!169.254.0.0/16',
'!fe80::/10',
'!127.0.0.0/8',
'!::1',
'!192.0.2.0/24',
'!198.51.100.0/24',
'!203.0.113.0/24',
'!2001:db8::/32',
],
],
'containing duplicates' => [
['10.0.0.1', '10.0.0.2', '10.0.0.2', '10.0.0.3'],
['10.0.0.1', '10.0.0.2', 3 => '10.0.0.3'],
],
];
}

/**
* @dataProvider dataGetRange
*/
public function testGetRange(array $ranges, array $expected): void
{
$ipRanges = new IpRanges($ranges);
$this->assertSame($expected, $ipRanges->getRanges());
}

public static function dataIsAllowed(): array
{
return [
[true, '192.168.10.11'],
[true, '10.0.0.1', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
[true, '192.168.5.101', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
[true, 'cafe::babe', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
[true, '10.0.1.2', ['10.0.1.0/24']],
[true, '10.0.1.2', ['10.0.1.0/24']],
[true, '127.0.0.1', ['!10.0.1.0/24', '10.0.0.0/8', 'localhost']],
[true, '10.0.1.2', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
[true, '127.0.0.1', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
[true, '10.0.1.28/28', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
[true, '2001:db0:1:1::6', ['2001:db0:1:1::/64']],
[true, '2001:db0:1:2::7', ['2001:db0:1:2::/64']],
[true, '2001:db0:1:2::7', ['2001:db0:1:2::/64', '!2001:db0::/32']],
[true, '10.0.1.2', ['10.0.1.0/24']],
[true, '2001:db0:1:2::7', ['10.0.1.0/24', '2001:db0:1:2::/64', '127.0.0.1']],
[true, '10.0.1.2', ['10.0.1.0/24', '2001:db0:1:2::/64', '127.0.0.1']],
[true, '8.8.8.8', ['!system', 'any']],
[true, '10.0.1.2', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
[true, '2001:db0:1:2::7', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
[true, '127.0.0.1', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
[true, '10.0.1.28/28', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
[true, '1.2.3.4', ['myNetworkEu'], ['myNetworkEu' => ['1.2.3.4/10', '5.6.7.8']]],
[true, '5.6.7.8', ['myNetworkEu'], ['myNetworkEu' => ['1.2.3.4/10', '5.6.7.8']]],
[false, 'babe::cafe', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
[false, '10.0.0.2', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
[false, '192.5.1.1', ['10.0.1.0/24']],
[false, '10.0.3.2', ['10.0.1.0/24']],
[false, '10.0.1.2', ['!10.0.1.0/24', '10.0.0.0/8', 'localhost']],
[false, '10.2.2.2', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
[false, '10.0.1.1/22', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
[false, '2001:db0:1:2::7', ['2001:db0:1:1::/64']],
[false, '2001:db0:1:2::7', ['!2001:db0::/32', '2001:db0:1:2::/64']],
[false, '192.5.1.1', ['10.0.1.0/24']],
[false, '2001:db0:1:2::7', ['10.0.1.0/24']],
[false, '10.0.3.2', ['10.0.1.0/24', '2001:db0:1:2::/64', '127.0.0.1']],
[false, '127.0.0.1', ['!system', 'any']],
[false, 'fe80::face', ['!system', 'any']],
[false, '10.2.2.2', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
[false, '10.0.1.1/22', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
];
}

/**
* @dataProvider dataIsAllowed
*/
public function testIsAllowed(bool $expected, string $ip, array $ranges = [], array $networks = []): void
{
$ipRanges = new IpRanges($ranges, $networks);
$this->assertSame($expected, $ipRanges->isAllowed($ip));
}
}

0 comments on commit 5b9f1e4

Please sign in to comment.