Passed
Pull Request — master (#475)
by Alexander
02:38
created

Ip::isAllowSubnet()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

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