Ip::prepareRanges()   A
last analyzed

Complexity

Conditions 6
Paths 7

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 0
Metric Value
cc 6
eloc 11
c 0
b 0
f 0
nc 7
nop 1
dl 0
loc 17
ccs 0
cts 0
cp 0
crap 42
rs 9.2222
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Validator\Rule;
6
7
use Attribute;
8
use Closure;
9
use InvalidArgumentException;
10
use Yiisoft\NetworkUtilities\IpHelper;
11
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
12
use Yiisoft\Validator\Rule\Trait\SkipOnErrorTrait;
13
use Yiisoft\Validator\Rule\Trait\WhenTrait;
14
use Yiisoft\Validator\DumpedRuleInterface;
15
use Yiisoft\Validator\SkipOnEmptyInterface;
16
use Yiisoft\Validator\SkipOnErrorInterface;
17
use Yiisoft\Validator\WhenInterface;
18
19
use function array_key_exists;
20
use function strlen;
21
22
/**
23
 * Checks if the value is a valid IPv4/IPv6 address or subnet.
24
 *
25
 * @psalm-import-type WhenType from WhenInterface
26
 *
27
 * @see IpHandler
28
 */
29
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
30
final class Ip implements DumpedRuleInterface, SkipOnErrorInterface, WhenInterface, SkipOnEmptyInterface
31
{
32
    use SkipOnEmptyTrait;
33
    use SkipOnErrorTrait;
34
    use WhenTrait;
35
36
    /**
37
     * Negation character.
38
     *
39
     * Used to negate {@see $ranges} or {@see $network} or to negate value validated when {@see $allowNegation}
40
     * is used.
41
     */
42
    private const NEGATION_CHARACTER = '!';
43
    /**
44
     * @psalm-var array<string, list<string>>
45
     *
46
     * @var array Default network aliases that can be used in {@see $ranges}.
47
     *
48
     * @see $networks
49
     */
50
    private array $defaultNetworks = [
51
        '*' => ['any'],
52
        'any' => ['0.0.0.0/0', '::/0'],
53
        'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
54
        'multicast' => ['224.0.0.0/4', 'ff00::/8'],
55
        'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
56
        'localhost' => ['127.0.0.0/8', '::1'],
57
        'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
58 12
        'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
59
    ];
60
61
    /**
62
     * @param array $networks Custom network aliases, that can be used in {@see $ranges}:
63
     *
64
     *  - key - alias name.
65
     *  - value - array of strings. String can be an IP range, IP address or another alias. String can be negated
66
     * with {@see NEGATION_CHARACTER} (independent of {@see $allowNegation} option).
67
     *
68
     * The following aliases are defined by default in {@see $defaultNetworks} and will be merged with custom ones:
69
     *
70
     *  - `*`: `any`.
71
     *  - `any`: `0.0.0.0/0, ::/0`.
72
     *  - `private`: `10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fd00::/8`.
73
     *  - `multicast`: `224.0.0.0/4, ff00::/8`.
74
     *  - `linklocal`: `169.254.0.0/16, fe80::/10`.
75
     *  - `localhost`: `127.0.0.0/8', ::1`.
76
     *  - `documentation`: `192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32`.
77
     *  - `system`: `multicast, linklocal, localhost, documentation`.
78
     *
79
     * @psalm-param array<string, list<string>> $networks
80
     *
81
     * @param bool $allowIpv4 Whether the validating value can be an IPv4 address. Defaults to `true`.
82
     * @param bool $allowIpv6 Whether the validating value can be an IPv6 address. Defaults to `true`.
83
     * @param bool $allowSubnet Whether the address can be an IP with CIDR subnet, like `192.168.10.0/24`.
84
     * The following values are possible:
85
     *
86
     * - `false` - the address must not have a subnet (default).
87
     * - `true` - specifying a subnet is optional.
88
     * @param bool $requireSubnet Whether subnet is required.
89
     * @param bool $allowNegation Whether an address may have a `!` negation character at the beginning.
90
     * @param string $incorrectInputMessage A message used when the input it incorrect.
91
     *
92
     * You may use the following placeholders in the message:
93
     *
94
     * - `{attribute}`: the translated label of the attribute being validated.
95
     * - `{type}`: the type of the value being validated.
96
     * @param string $message Error message used when validation fails due to the wrong IP address format.
97
     *
98
     * You may use the following placeholders in the message:
99
     *
100
     * - `{attribute}`: the translated label of the attribute being validated.
101
     * - `{value}`: the value being validated.
102
     * @param string $ipv4NotAllowedMessage Error message used when validation fails due to the disabled IPv4
103
     * validation when {@see $allowIpv4} is set.
104
     *
105
     * You may use the following placeholders in the message:
106
     *
107
     * - `{attribute}`: the translated label of the attribute being validated.
108
     * - `{value}`: the value being validated.
109
     * @param string $ipv6NotAllowedMessage Error message used when validation fails due to the disabled IPv6
110
     * validation when {@see $allowIpv6} is set.
111
     *
112
     * You may use the following placeholders in the message:
113
     *
114
     * - `{attribute}`: the translated label of the attribute being validated.
115
     * - `{value}`: the value being validated.
116
     * @param string $wrongCidrMessage string Error message used when validation fails due to the wrong CIDR when
117
     * {@see $allowSubnet} is set.
118
     *
119
     * You may use the following placeholders in the message:
120
     *
121
     * - `{attribute}`: the translated label of the attribute being validated.
122
     * - `{value}`: the value being validated.
123
     * @param string $noSubnetMessage Error message used when validation fails due to {@see $allowSubnet} is used, but
124
     * the CIDR prefix is not set.
125
     *
126
     * You may use the following placeholders in the message:
127
     *
128
     * - `{attribute}`: the translated label of the attribute being validated.
129
     * - `{value}`: the value being validated.
130
     * @param string $hasSubnetMessage Error message used when validation fails due to {@see $allowSubnet} is false, but
131
     * CIDR prefix is present.
132
     *
133
     * You may use the following placeholders in the message:
134
     *
135
     * - `{attribute}`: the translated label of the attribute being validated.
136
     * - `{value}`: the value being validated.
137
     * @param string $notInRangeMessage Error message used when validation fails due to IP address is not allowed by
138
     * {@see $ranges} check.
139
     *
140
     * You may use the following placeholders in the message:
141
     *
142
     * - `{attribute}`: the translated label of the attribute being validated.
143
     * - `{value}`: the value being validated.
144
     * @param string[] $ranges The IPv4 or IPv6 ranges that are allowed or forbidden.
145
     *
146
     * The following preparation tasks are performed:
147
     *
148
     * - Recursively substitute aliases (described in {@see $networks}) with their values.
149
     * - Remove duplicates.
150
     *
151
     * When the array is empty or the option not set, all IP addresses are allowed.
152
     *
153
     * Otherwise, the rules are checked sequentially until the first match is found. An IP address is forbidden,
154
     * when it has not matched any of the rules.
155
     *
156
     * Example:
157
     *
158
     * ```php
159
     * new Ip(ranges: [
160
     *     '192.168.10.128'
161
     *     '!192.168.10.0/24',
162
     *     'any' // allows any other IP addresses
163
     * ]);
164
     * ```
165
     *
166
     * In this example, access is allowed for all the IPv4 and IPv6 addresses excluding the `192.168.10.0/24`
167
     * subnet. IPv4 address `192.168.10.128` is also allowed, because it is listed before the restriction.
168
     * @param bool|callable|null $skipOnEmpty Whether to skip this rule if the validated value is empty / not passed.
169
     * See {@see SkipOnEmptyInterface}.
170
     * @param bool $skipOnError Whether to skip this rule if any of the previous rules gave an error. See
171
     * {@see SkipOnErrorInterface}.
172
     * @param Closure|null $when A callable to define a condition for applying the rule. See {@see WhenInterface}.
173
     *
174
     * @psalm-param WhenType $when
175
     *
176
     * @throws InvalidArgumentException If configuration is wrong.
177
     */
178
    public function __construct(
179
        private array $networks = [],
180
        private bool $allowIpv4 = true,
181
        private bool $allowIpv6 = true,
182
        private bool $allowSubnet = false,
183
        private bool $requireSubnet = false,
184
        private bool $allowNegation = false,
185
        private string $incorrectInputMessage = 'The value must be a string.',
186
        private string $message = 'Must be a valid IP address.',
187
        private string $ipv4NotAllowedMessage = 'Must not be an IPv4 address.',
188
        private string $ipv6NotAllowedMessage = 'Must not be an IPv6 address.',
189
        private string $wrongCidrMessage = 'Contains wrong subnet mask.',
190
        private string $noSubnetMessage = 'Must be an IP address with specified subnet.',
191
        private string $hasSubnetMessage = 'Must not be a subnet.',
192
        private string $notInRangeMessage = 'Is not in the allowed range.',
193
        private array $ranges = [],
194
        private mixed $skipOnEmpty = null,
195
        private bool $skipOnError = false,
196
        private Closure|null $when = null,
197
    ) {
198
        if (!$this->allowIpv4 && !$this->allowIpv6) {
199
            throw new InvalidArgumentException('Both IPv4 and IPv6 checks can not be disabled at the same time.');
200
        }
201
202
        foreach ($networks as $key => $_values) {
203
            if (array_key_exists($key, $this->defaultNetworks)) {
204
                throw new InvalidArgumentException("Network alias \"{$key}\" already set as default.");
205
            }
206 12
        }
207 1
208
        $this->networks = array_merge($this->defaultNetworks, $this->networks);
209
210 11
        if ($requireSubnet) {
211 2
            // Might be a bug of XDebug, because this line is covered by tests (see "IpTest").
212 1
            // @codeCoverageIgnoreStart
213
            $this->allowSubnet = true;
214
            // @codeCoverageIgnoreEnd
215
        }
216 10
217
        $this->ranges = $this->prepareRanges($ranges);
218 10
    }
219
220
    public function getName(): string
221
    {
222
        return self::class;
223
    }
224
225 10
    /**
226
     * Get custom network aliases, that can be used in {@see $ranges}.
227
     *
228 1
     * @return array Network aliases.
229
     *
230 1
     * @see $networks
231
     */
232
    public function getNetworks(): array
233 2
    {
234
        return $this->networks;
235 2
    }
236
237
    /**
238 54
     * Whether the validating value can be an IPv4 address
239
     *
240 54
     * @return bool Whether the validating value can be an IPv4 address. Defaults to `true`.
241
     *
242
     * @see $allowIpv4
243 31
     */
244
    public function isIpv4Allowed(): bool
245 31
    {
246
        return $this->allowIpv4;
247
    }
248 38
249
    /**
250 38
     * Whether the validating value can be an IPv6 address.
251
     *
252
     * @return bool Whether the validating value can be an IPv6 address. Defaults to `true`.
253 62
     *
254
     * @see $allowIpv6
255 62
     */
256
    public function isIpv6Allowed(): bool
257
    {
258 7
        return $this->allowIpv6;
259
    }
260 7
261
    /**
262
     * Whether the address can be an IP with CIDR subnet, like `192.168.10.0/24`.
263 8
     *
264
     * @return bool Whether the address can be an IP with CIDR subnet.
265 8
     *
266
     * @see $allowSubnet
267
     */
268 29
    public function isSubnetAllowed(): bool
269
    {
270 29
        return $this->allowSubnet;
271
    }
272
273 4
    /**
274
     * Whether subnet is required.
275 4
     *
276
     * @return bool Whether subnet is required.
277
     *
278 4
     * @see $requireSubnet
279
     */
280 4
    public function isSubnetRequired(): bool
281
    {
282
        return $this->requireSubnet;
283 5
    }
284
285 5
    /**
286
     * Whether an address may have a `!` negation character at the beginning.
287
     *
288 7
     * @return bool Whether an address may have a `!` negation character at the beginning.
289
     *
290 7
     * @see $allowNegation
291
     */
292
    public function isNegationAllowed(): bool
293 5
    {
294
        return $this->allowNegation;
295 5
    }
296
297
    /**
298 19
     * Get a message used when the input it incorrect.
299
     *
300 19
     * @return string Error message
301
     *
302
     * @see $incorrectInputMessage
303 5
     */
304
    public function getIncorrectInputMessage(): string
305 5
    {
306
        return $this->incorrectInputMessage;
307
    }
308
309
    /**
310
     * Get an error message used when validation fails due to the wrong IP address format.
311
     *
312
     * @return string Error message.
313
     *
314
     * @see $message
315 46
     */
316
    public function getMessage(): string
317 46
    {
318 46
        return $this->message;
319
    }
320
321
    /**
322
     * Get an error message used when validation fails due to the disabled IPv4 validation when
323
     * {@see $allowIpv4} is set.
324
     *
325
     * @return string Error message.
326
     *
327
     * @see $ipv4NotAllowedMessage
328
     */
329
    public function getIpv4NotAllowedMessage(): string
330
    {
331 10
        return $this->ipv4NotAllowedMessage;
332
    }
333 10
334 10
    /**
335 5
     * Get error message used when validation fails due to the disabled IPv6 validation when
336 5
     * {@see $allowIpv6} is set.
337 3
     *
338 3
     * @return string Error message.
339 3
     *
340 3
     * @see $ipv6NotAllowedMessage
341
     */
342
    public function getIpv6NotAllowedMessage(): string
343 5
    {
344
        return $this->ipv6NotAllowedMessage;
345
    }
346
347 10
    /**
348
     * Get error message used when validation fails due to the wrong CIDR when
349
     * {@see $allowSubnet} is set.
350
     *
351
     * @return string Error message.
352
     *
353 72
     * @see $wrongCidrMessage
354
     */
355 72
    public function getWrongCidrMessage(): string
356 31
    {
357
        return $this->wrongCidrMessage;
358
    }
359 41
360 41
    /**
361 41
     * Get error message used when validation fails due to {@see $allowSubnet} is used, but
362 35
     * the CIDR prefix is not set.
363
     *
364
     * @return string Error message.
365
     *
366 6
     * @see $getNoSubnetMessage
367
     */
368
    public function getNoSubnetMessage(): string
369 7
    {
370
        return $this->noSubnetMessage;
371
    }
372 7
373 7
    /**
374 7
     * Get error message used when validation fails due to {@see $allowSubnet} is false, but
375 7
     * CIDR prefix is present.
376 7
     *
377 7
     * @return string Error message.
378
     *
379 7
     * @see $hasSubnetMessage
380
     */
381
    public function getHasSubnetMessage(): string
382
    {
383 7
        return $this->hasSubnetMessage;
384
    }
385
386
    /**
387 7
     * Get error message used when validation fails due to IP address is not allowed by
388
     * {@see $ranges} check.
389
     *
390
     * @return string Error message.
391 7
     *
392
     * @see $notInRangeMessage
393
     */
394
    public function getNotInRangeMessage(): string
395 7
    {
396
        return $this->notInRangeMessage;
397
    }
398
399 7
    /**
400
     * Get the IPv4 or IPv6 ranges that are allowed or forbidden.
401
     *
402
     * @return string[] The IPv4 or IPv6 ranges that are allowed or forbidden.
403 7
     *
404
     * @see $ranges
405
     */
406
    public function getRanges(): array
407 7
    {
408
        return $this->ranges;
409
    }
410 7
411 7
    /**
412 7
     * Parses IP address/range for the negation with {@see NEGATION_CHARACTER}.
413
     *
414
     * @return array The result array consists of 2 elements:
415
     * - `boolean`: whether the string is negated
416 134
     * - `string`: the string without negation (when the negation were present)
417
     *
418 134
     * @psalm-return array{0: bool, 1: string}
419
     */
420
    private function parseNegatedRange(string $string): array
421
    {
422
        $isNegated = str_starts_with($string, self::NEGATION_CHARACTER);
423
        return [$isNegated, $isNegated ? substr($string, strlen(self::NEGATION_CHARACTER)) : $string];
424
    }
425
426
    /**
427
     * Prepares array to fill in {@see $ranges}:
428
     *
429
     *  - Recursively substitutes aliases, described in {@see $networks} with their values.
430
     *  - Removes duplicates.
431
     *
432
     * @param string[] $ranges
433
     *
434
     * @return string[]
435
     */
436
    private function prepareRanges(array $ranges): array
437
    {
438
        $result = [];
439
        foreach ($ranges as $string) {
440
            [$isRangeNegated, $range] = $this->parseNegatedRange($string);
441
            if (isset($this->networks[$range])) {
442
                $replacements = $this->prepareRanges($this->networks[$range]);
443
                foreach ($replacements as &$replacement) {
444
                    [$isReplacementNegated, $replacement] = $this->parseNegatedRange($replacement);
445
                    $result[] = ($isRangeNegated && !$isReplacementNegated ? self::NEGATION_CHARACTER : '') . $replacement;
446
                }
447
            } else {
448
                $result[] = $string;
449
            }
450
        }
451
452
        return array_unique($result);
453
    }
454
455
    /**
456
     * Whether the IP address with specified CIDR is allowed according to the {@see $ranges} list.
457
     */
458
    public function isAllowed(string $ip): bool
459
    {
460
        if (empty($this->ranges)) {
461
            return true;
462
        }
463
464
        foreach ($this->ranges as $string) {
465
            [$isNegated, $range] = $this->parseNegatedRange($string);
466
            if (IpHelper::inRange($ip, $range)) {
467
                return !$isNegated;
468
            }
469
        }
470
471
        return false;
472
    }
473
474
    public function getOptions(): array
475
    {
476
        return [
477
            'networks' => $this->networks,
478
            'allowIpv4' => $this->allowIpv4,
479
            'allowIpv6' => $this->allowIpv6,
480
            'allowSubnet' => $this->allowSubnet,
481
            'requireSubnet' => $this->requireSubnet,
482
            'allowNegation' => $this->allowNegation,
483
            'incorrectInputMessage' => [
484
                'template' => $this->incorrectInputMessage,
485
                'parameters' => [],
486
            ],
487
            'message' => [
488
                'template' => $this->message,
489
                'parameters' => [],
490
            ],
491
            'ipv4NotAllowedMessage' => [
492
                'template' => $this->ipv4NotAllowedMessage,
493
                'parameters' => [],
494
            ],
495
            'ipv6NotAllowedMessage' => [
496
                'template' => $this->ipv6NotAllowedMessage,
497
                'parameters' => [],
498
            ],
499
            'wrongCidrMessage' => [
500
                'template' => $this->wrongCidrMessage,
501
                'parameters' => [],
502
            ],
503
            'noSubnetMessage' => [
504
                'template' => $this->noSubnetMessage,
505
                'parameters' => [],
506
            ],
507
            'hasSubnetMessage' => [
508
                'template' => $this->hasSubnetMessage,
509
                'parameters' => [],
510
            ],
511
            'notInRangeMessage' => [
512
                'template' => $this->notInRangeMessage,
513
                'parameters' => [],
514
            ],
515
            'ranges' => $this->ranges,
516
            'skipOnEmpty' => $this->getSkipOnEmptyOption(),
517
            'skipOnError' => $this->skipOnError,
518
        ];
519
    }
520
521
    public function getHandler(): string
522
    {
523
        return IpHandler::class;
524
    }
525
}
526