Skip to content

Commit 5b9f1e4

Browse files
vjiksamdark
andauthored
Add IpRanges object (#63)
Co-authored-by: Alexander Makarov <[email protected]>
1 parent cf739a9 commit 5b9f1e4

File tree

4 files changed

+382
-0
lines changed

4 files changed

+382
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 1.0.2 under development
44

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

78
## 1.0.1 January 27, 2022

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,29 @@ if (!DnsHelper::existsA('yiiframework.com')) {
7373
}
7474
```
7575

76+
### `IpRanges`
77+
78+
```php
79+
use Yiisoft\NetworkUtilities\IpRanges;
80+
81+
$ipRanges = new IpRanges(
82+
[
83+
'10.0.1.0/24',
84+
'2001:db0:1:2::/64',
85+
IpRanges::LOCALHOST,
86+
'myNetworkEu',
87+
'!' . IpRanges::ANY,
88+
],
89+
[
90+
'myNetworkEu' => ['1.2.3.4/10', '5.6.7.8'],
91+
],
92+
);
93+
94+
$ipRanges->isAllowed('10.0.1.28/28'); // true
95+
$ipRanges->isAllowed('1.2.3.4'); // true
96+
$ipRanges->isAllowed('192.168.0.1'); // false
97+
```
98+
7699
## Documentation
77100

78101
- [Internals](docs/internals.md)

src/IpRanges.php

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\NetworkUtilities;
6+
7+
use InvalidArgumentException;
8+
9+
use function array_key_exists;
10+
use function array_merge;
11+
use function array_unique;
12+
use function strlen;
13+
use function strpos;
14+
15+
/**
16+
* `IpRanges` represents a set of IP ranges that are either allowed or forbidden.
17+
*/
18+
final class IpRanges
19+
{
20+
public const ANY = 'any';
21+
public const PRIVATE = 'private';
22+
public const MULTICAST = 'multicast';
23+
public const LINK_LOCAL = 'linklocal';
24+
public const LOCALHOST = 'localhost';
25+
public const DOCUMENTATION = 'documentation';
26+
public const SYSTEM = 'system';
27+
28+
/**
29+
* Default network aliases.
30+
* @see https://datatracker.ietf.org/doc/html/rfc5735#section-4
31+
*/
32+
public const DEFAULT_NETWORKS = [
33+
'*' => [self::ANY],
34+
self::ANY => ['0.0.0.0/0', '::/0'],
35+
self::PRIVATE => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
36+
self::MULTICAST => ['224.0.0.0/4', 'ff00::/8'],
37+
self::LINK_LOCAL => ['169.254.0.0/16', 'fe80::/10'],
38+
self::LOCALHOST => ['127.0.0.0/8', '::1'],
39+
self::DOCUMENTATION => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
40+
self::SYSTEM => [self::MULTICAST, self::LINK_LOCAL, self::LOCALHOST, self::DOCUMENTATION],
41+
];
42+
43+
/**
44+
* @var string[]
45+
*/
46+
private array $ranges;
47+
48+
/**
49+
* @psalm-var array<string, list<string>>
50+
*/
51+
private array $networks;
52+
53+
/**
54+
* @param string[] $ranges The IPv4 or IPv6 ranges that are either allowed or forbidden.
55+
*
56+
* The following preparation tasks are performed:
57+
* - recursively substitute aliases (described in {@see $networks}) with their values;
58+
* - remove duplicates.
59+
*
60+
* When the array is empty or the option is not set, all IP addresses are allowed.
61+
*
62+
* Otherwise, the rules are checked sequentially until the first match is found. An IP address is forbidden
63+
* when it hasn't matched any of the rules.
64+
*
65+
* Example:
66+
*
67+
* ```php
68+
* new Ip(ranges: [
69+
* '192.168.10.128'
70+
* '!192.168.10.0/24',
71+
* 'any' // allows any other IP addresses
72+
* ]);
73+
* ```
74+
*
75+
* In this example, access is allowed for all the IPv4 and IPv6 addresses excluding the `192.168.10.0/24`
76+
* subnet. IPv4 address `192.168.10.128` is also allowed, because it is listed before the restriction.
77+
* @param array $networks Custom network aliases, that can be used in {@see $ranges}:
78+
* - key - alias name;
79+
* - value - array of strings. String can be an IP range, IP address or another alias. String can be negated
80+
* with `!` character.
81+
* The default aliases are defined in {@see self::DEFAULT_NETWORKS} and will be merged with custom ones.
82+
*
83+
* @psalm-param array<string, list<string>> $networks
84+
*/
85+
public function __construct(array $ranges = [], array $networks = [])
86+
{
87+
foreach ($networks as $key => $_values) {
88+
if (array_key_exists($key, self::DEFAULT_NETWORKS)) {
89+
throw new InvalidArgumentException("Network alias \"{$key}\" already set as default.");
90+
}
91+
}
92+
$this->networks = array_merge(self::DEFAULT_NETWORKS, $networks);
93+
94+
$this->ranges = $this->prepareRanges($ranges);
95+
}
96+
97+
/**
98+
* Get the IPv4 or IPv6 ranges that are either allowed or forbidden.
99+
*
100+
* @return string[] The IPv4 or IPv6 ranges that are either allowed or forbidden.
101+
*/
102+
public function getRanges(): array
103+
{
104+
return $this->ranges;
105+
}
106+
107+
/**
108+
* Get network aliases, that can be used in {@see $ranges}.
109+
*
110+
* @return array Network aliases.
111+
*
112+
* @see $networks
113+
*/
114+
public function getNetworks(): array
115+
{
116+
return $this->networks;
117+
}
118+
119+
/**
120+
* Whether the IP address with specified CIDR is allowed according to the {@see $ranges} list.
121+
*/
122+
public function isAllowed(string $ip): bool
123+
{
124+
if (empty($this->ranges)) {
125+
return true;
126+
}
127+
128+
foreach ($this->ranges as $string) {
129+
[$isNegated, $range] = $this->parseNegatedRange($string);
130+
if (IpHelper::inRange($ip, $range)) {
131+
return !$isNegated;
132+
}
133+
}
134+
135+
return false;
136+
}
137+
138+
/**
139+
* Prepares array to fill in {@see $ranges}:
140+
* - recursively substitutes aliases, described in `$networks` argument with their values;
141+
* - removes duplicates.
142+
*
143+
* @param string[] $ranges
144+
* @return string[]
145+
*/
146+
private function prepareRanges(array $ranges): array
147+
{
148+
$result = [];
149+
foreach ($ranges as $string) {
150+
[$isRangeNegated, $range] = $this->parseNegatedRange($string);
151+
if (isset($this->networks[$range])) {
152+
$replacements = $this->prepareRanges($this->networks[$range]);
153+
foreach ($replacements as &$replacement) {
154+
[$isReplacementNegated, $replacement] = $this->parseNegatedRange($replacement);
155+
$result[] = ($isRangeNegated && !$isReplacementNegated ? '!' : '') . $replacement;
156+
}
157+
} else {
158+
$result[] = $string;
159+
}
160+
}
161+
162+
return array_unique($result);
163+
}
164+
165+
/**
166+
* Parses IP address/range for the negation with `!`.
167+
*
168+
* @return array The result array consists of 2 elements:
169+
* - `boolean` - whether the string is negated;
170+
* - `string` - the string without negation (when the negation were present).
171+
*
172+
* @psalm-return array{0: bool, 1: string}
173+
*/
174+
private function parseNegatedRange(string $string): array
175+
{
176+
$isNegated = strpos($string, '!') === 0;
177+
return [$isNegated, $isNegated ? substr($string, strlen('!')) : $string];
178+
}
179+
}

tests/IpRangesTest.php

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\NetworkUtilities\Tests;
6+
7+
use InvalidArgumentException;
8+
use PHPUnit\Framework\TestCase;
9+
use Yiisoft\NetworkUtilities\IpRanges;
10+
11+
final class IpRangesTest extends TestCase
12+
{
13+
public function testReadmeExample(): void
14+
{
15+
$ipRanges = new IpRanges(
16+
[
17+
'10.0.1.0/24',
18+
'2001:db0:1:2::/64',
19+
IpRanges::LOCALHOST,
20+
'myNetworkEu',
21+
'!' . IpRanges::ANY,
22+
],
23+
[
24+
'myNetworkEu' => ['1.2.3.4/10', '5.6.7.8'],
25+
],
26+
);
27+
28+
$this->assertTrue($ipRanges->isAllowed('10.0.1.28/28'));
29+
$this->assertTrue($ipRanges->isAllowed('1.2.3.4'));
30+
$this->assertFalse($ipRanges->isAllowed('192.168.0.1'));
31+
}
32+
33+
public function testNetworkAliasException(): void
34+
{
35+
$this->expectException(InvalidArgumentException::class);
36+
$this->expectExceptionMessage('Network alias "*" already set as default');
37+
new IpRanges(['*'], ['*' => ['wrong']]);
38+
}
39+
40+
public static function dataGetNetworks(): array
41+
{
42+
return [
43+
'default' => [
44+
[],
45+
[
46+
'*' => ['any'],
47+
'any' => ['0.0.0.0/0', '::/0'],
48+
'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
49+
'multicast' => ['224.0.0.0/4', 'ff00::/8'],
50+
'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
51+
'localhost' => ['127.0.0.0/8', '::1'],
52+
'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
53+
'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
54+
],
55+
],
56+
'custom' => [
57+
['custom' => ['1.1.1.1/1', '2.2.2.2/2']],
58+
[
59+
'*' => ['any'],
60+
'any' => ['0.0.0.0/0', '::/0'],
61+
'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
62+
'multicast' => ['224.0.0.0/4', 'ff00::/8'],
63+
'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
64+
'localhost' => ['127.0.0.0/8', '::1'],
65+
'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
66+
'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
67+
'custom' => ['1.1.1.1/1', '2.2.2.2/2'],
68+
],
69+
],
70+
];
71+
}
72+
73+
/**
74+
* @dataProvider dataGetNetworks
75+
*/
76+
public function testGetNetworks(array $networks, array $expected): void
77+
{
78+
$ipRanges = new IpRanges([], $networks);
79+
$this->assertSame($expected, $ipRanges->getNetworks());
80+
}
81+
82+
public static function dataGetRange(): array
83+
{
84+
return [
85+
'ipv4' => [['10.0.0.1'], ['10.0.0.1']],
86+
'any' => [['192.168.0.32', 'fa::/32', 'any'], ['192.168.0.32', 'fa::/32', '0.0.0.0/0', '::/0']],
87+
'ipv4+!private' => [
88+
['10.0.0.1', '!private'],
89+
['10.0.0.1', '!10.0.0.0/8', '!172.16.0.0/12', '!192.168.0.0/16', '!fd00::/8'],
90+
],
91+
'private+!system' => [
92+
['private', '!system'],
93+
[
94+
'10.0.0.0/8',
95+
'172.16.0.0/12',
96+
'192.168.0.0/16',
97+
'fd00::/8',
98+
'!224.0.0.0/4',
99+
'!ff00::/8',
100+
'!169.254.0.0/16',
101+
'!fe80::/10',
102+
'!127.0.0.0/8',
103+
'!::1',
104+
'!192.0.2.0/24',
105+
'!198.51.100.0/24',
106+
'!203.0.113.0/24',
107+
'!2001:db8::/32',
108+
],
109+
],
110+
'containing duplicates' => [
111+
['10.0.0.1', '10.0.0.2', '10.0.0.2', '10.0.0.3'],
112+
['10.0.0.1', '10.0.0.2', 3 => '10.0.0.3'],
113+
],
114+
];
115+
}
116+
117+
/**
118+
* @dataProvider dataGetRange
119+
*/
120+
public function testGetRange(array $ranges, array $expected): void
121+
{
122+
$ipRanges = new IpRanges($ranges);
123+
$this->assertSame($expected, $ipRanges->getRanges());
124+
}
125+
126+
public static function dataIsAllowed(): array
127+
{
128+
return [
129+
[true, '192.168.10.11'],
130+
[true, '10.0.0.1', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
131+
[true, '192.168.5.101', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
132+
[true, 'cafe::babe', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
133+
[true, '10.0.1.2', ['10.0.1.0/24']],
134+
[true, '10.0.1.2', ['10.0.1.0/24']],
135+
[true, '127.0.0.1', ['!10.0.1.0/24', '10.0.0.0/8', 'localhost']],
136+
[true, '10.0.1.2', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
137+
[true, '127.0.0.1', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
138+
[true, '10.0.1.28/28', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
139+
[true, '2001:db0:1:1::6', ['2001:db0:1:1::/64']],
140+
[true, '2001:db0:1:2::7', ['2001:db0:1:2::/64']],
141+
[true, '2001:db0:1:2::7', ['2001:db0:1:2::/64', '!2001:db0::/32']],
142+
[true, '10.0.1.2', ['10.0.1.0/24']],
143+
[true, '2001:db0:1:2::7', ['10.0.1.0/24', '2001:db0:1:2::/64', '127.0.0.1']],
144+
[true, '10.0.1.2', ['10.0.1.0/24', '2001:db0:1:2::/64', '127.0.0.1']],
145+
[true, '8.8.8.8', ['!system', 'any']],
146+
[true, '10.0.1.2', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
147+
[true, '2001:db0:1:2::7', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
148+
[true, '127.0.0.1', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
149+
[true, '10.0.1.28/28', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
150+
[true, '1.2.3.4', ['myNetworkEu'], ['myNetworkEu' => ['1.2.3.4/10', '5.6.7.8']]],
151+
[true, '5.6.7.8', ['myNetworkEu'], ['myNetworkEu' => ['1.2.3.4/10', '5.6.7.8']]],
152+
[false, 'babe::cafe', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
153+
[false, '10.0.0.2', ['10.0.0.1', '!10.0.0.0/8', '!babe::/8', 'any']],
154+
[false, '192.5.1.1', ['10.0.1.0/24']],
155+
[false, '10.0.3.2', ['10.0.1.0/24']],
156+
[false, '10.0.1.2', ['!10.0.1.0/24', '10.0.0.0/8', 'localhost']],
157+
[false, '10.2.2.2', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
158+
[false, '10.0.1.1/22', ['10.0.1.0/24', '!10.0.0.0/8', 'localhost']],
159+
[false, '2001:db0:1:2::7', ['2001:db0:1:1::/64']],
160+
[false, '2001:db0:1:2::7', ['!2001:db0::/32', '2001:db0:1:2::/64']],
161+
[false, '192.5.1.1', ['10.0.1.0/24']],
162+
[false, '2001:db0:1:2::7', ['10.0.1.0/24']],
163+
[false, '10.0.3.2', ['10.0.1.0/24', '2001:db0:1:2::/64', '127.0.0.1']],
164+
[false, '127.0.0.1', ['!system', 'any']],
165+
[false, 'fe80::face', ['!system', 'any']],
166+
[false, '10.2.2.2', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
167+
[false, '10.0.1.1/22', ['10.0.1.0/24', '2001:db0:1:2::/64', 'localhost', '!any']],
168+
];
169+
}
170+
171+
/**
172+
* @dataProvider dataIsAllowed
173+
*/
174+
public function testIsAllowed(bool $expected, string $ip, array $ranges = [], array $networks = []): void
175+
{
176+
$ipRanges = new IpRanges($ranges, $networks);
177+
$this->assertSame($expected, $ipRanges->isAllowed($ip));
178+
}
179+
}

0 commit comments

Comments
 (0)