Passed
Pull Request — master (#475)
by Alexander
29:15 queued 26:48
created

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