Passed
Pull Request — master (#300)
by Alexander
06:13 queued 03:06
created

Ip::isAllowNegation()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
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 RuntimeException;
10
use Yiisoft\NetworkUtilities\IpHelper;
11
use Yiisoft\Validator\BeforeValidationInterface;
12
use Yiisoft\Validator\Rule\Trait\BeforeValidationTrait;
13
use Yiisoft\Validator\Rule\Trait\RuleNameTrait;
14
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
15
use Yiisoft\Validator\SerializableRuleInterface;
16
use Yiisoft\Validator\SkipOnEmptyInterface;
17
use Yiisoft\Validator\ValidationContext;
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
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
28
final class Ip implements SerializableRuleInterface, BeforeValidationInterface, SkipOnEmptyInterface
29
{
30
    use BeforeValidationTrait;
31
    use RuleNameTrait;
32
    use SkipOnEmptyTrait;
33
34
    /**
35
     * Negation char.
36
     *
37
     * Used to negate {@see $ranges} or {@see $network} or to negate validating value when {@see $allowNegation}
38
     * is used.
39
     */
40
    private const NEGATION_CHAR = '!';
41
    /**
42
     * @see $networks
43
     */
44
    private array $defaultNetworks = [
45
        '*' => ['any'],
46
        'any' => ['0.0.0.0/0', '::/0'],
47
        'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
48
        'multicast' => ['224.0.0.0/4', 'ff00::/8'],
49
        'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
50
        'localhost' => ['127.0.0.0/8', '::1'],
51
        'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
52
        'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
53
    ];
54
55 8
    public function __construct(
56
        /**
57
         * @var array Custom network aliases, that can be used in {@see $ranges}.
58
         *
59
         *  - key - alias name
60
         *  - value - array of strings. String can be an IP range, IP address or another alias. String can be
61
         *    negated with {@see NEGATION_CHAR} (independent of {@see $allowNegation} option).
62
         *
63
         * The following aliases are defined by default in {@see $defaultNetworks} and will be merged with custom ones:
64
         *
65
         *  - `*`: `any`
66
         *  - `any`: `0.0.0.0/0, ::/0`
67
         *  - `private`: `10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fd00::/8`
68
         *  - `multicast`: `224.0.0.0/4, ff00::/8`
69
         *  - `linklocal`: `169.254.0.0/16, fe80::/10`
70
         *  - `localhost`: `127.0.0.0/8', ::1`
71
         *  - `documentation`: `192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32`
72
         *  - `system`: `multicast, linklocal, localhost, documentation`
73
         *
74
         * @see $defaultNetworks
75
         */
76
        private array $networks = [],
77
        /**
78
         * @var bool whether the validating value can be an IPv4 address. Defaults to `true`.
79
         */
80
        private bool $allowIpv4 = true,
81
        /**
82
         * @var bool whether the validating value can be an IPv6 address. Defaults to `true`.
83
         */
84
        private bool $allowIpv6 = true,
85
        /**
86
         * @var bool whether the address can be an IP with CIDR subnet, like `192.168.10.0/24`.
87
         * The following values are possible:
88
         *
89
         * - `false` - the address must not have a subnet (default).
90
         * - `true` - specifying a subnet is optional.
91
         */
92
        private bool $allowSubnet = false,
93
        private bool $requireSubnet = false,
94
        /**
95
         * @var bool whether address may have a {@see NEGATION_CHAR} character at the beginning.
96
         * Defaults to `false`.
97
         */
98
        private bool $allowNegation = false,
99
        /**
100
         * @var string user-defined error message is used when validation fails due to the wrong IP address format.
101
         *
102
         * You may use the following placeholders in the message:
103
         *
104
         * - `{attribute}`: the label of the attribute being validated
105
         * - `{value}`: the value of the attribute being validated
106
         */
107
        private string $message = 'Must be a valid IP address.',
108
        /**
109
         * @var string user-defined error message is used when validation fails due to the disabled IPv4 validation.
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
         *
116
         * @see $allowIpv4
117
         */
118
        private string $ipv4NotAllowedMessage = 'Must not be an IPv4 address.',
119
        /**
120
         * @var string user-defined error message is used when validation fails due to the disabled IPv6 validation.
121
         *
122
         * You may use the following placeholders in the message:
123
         *
124
         * - `{attribute}`: the label of the attribute being validated
125
         * - `{value}`: the value of the attribute being validated
126
         *
127
         * @see $allowIpv6
128
         */
129
        private string $ipv6NotAllowedMessage = 'Must not be an IPv6 address.',
130
        /**
131
         * @var string user-defined error message is used when validation fails due to the wrong CIDR.
132
         *
133
         * You may use the following placeholders in the message:
134
         *
135
         * - `{attribute}`: the label of the attribute being validated
136
         * - `{value}`: the value of the attribute being validated
137
         *
138
         * @see $allowSubnet
139
         */
140
        private string $wrongCidrMessage = 'Contains wrong subnet mask.',
141
        /**
142
         * @var string user-defined error message is used when validation fails due to subnet {@see $allowSubnet} is
143
         * used, but the CIDR prefix is not set.
144
         *
145
         * You may use the following placeholders in the message:
146
         *
147
         * - `{attribute}`: the label of the attribute being validated
148
         * - `{value}`: the value of the attribute being validated
149
         *
150
         * @see $allowSubnet
151
         */
152
        private string $noSubnetMessage = 'Must be an IP address with specified subnet.',
153
        /**
154
         * @var string user-defined error message is used when validation fails
155
         * due to {@see $allowSubnet} is false, but CIDR prefix is present.
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
         * @see $allowSubnet
163
         */
164
        private string $hasSubnetMessage = 'Must not be a subnet.',
165
        /**
166
         * @var string user-defined error message is used when validation fails due to IP address
167
         * is not allowed by {@see $ranges} check.
168
         *
169
         * You may use the following placeholders in the message:
170
         *
171
         * - `{attribute}`: the label of the attribute being validated
172
         * - `{value}`: the value of the attribute being validated
173
         *
174
         * @see $ranges
175
         */
176
        private string $notInRangeMessage = 'Is not in the allowed range.',
177
        /**
178
         * @var string[] The IPv4 or IPv6 ranges that are allowed or forbidden.
179
         *
180
         * The following preparation tasks are performed:
181
         *
182
         * - Recursively substitutes aliases (described in {@see $networks}) with their values.
183
         * - Removes duplicates.
184
         *
185
         * When the array is empty, or the option not set, all IP addresses are allowed.
186
         *
187
         * Otherwise, the rules are checked sequentially until the first match is found.
188
         * An IP address is forbidden, when it has not matched any of the rules.
189
         *
190
         * Example:
191
         *
192
         * ```php
193
         * (new Ip(ranges: [
194
         *     '192.168.10.128'
195
         *     '!192.168.10.0/24',
196
         *     'any' // allows any other IP addresses
197
         * ]);
198
         * ```
199
         *
200
         * In this example, access is allowed for all the IPv4 and IPv6 addresses excluding the `192.168.10.0/24`
201
         * subnet. IPv4 address `192.168.10.128` is also allowed, because it is listed before the restriction.
202
         */
203
        private array $ranges = [],
204
205
        /**
206
         * @var bool|callable|null
207
         */
208
        private $skipOnEmpty = null,
209
        private bool $skipOnError = false,
210
        /**
211
         * @var Closure(mixed, ValidationContext):bool|null
212
         */
213
        private ?Closure $when = null,
214
    ) {
215 8
        foreach ($networks as $key => $_values) {
216 1
            if (array_key_exists($key, $this->defaultNetworks)) {
217 1
                throw new RuntimeException("Network alias \"{$key}\" already set as default.");
218
            }
219
        }
220
221 7
        $this->networks = array_merge($this->defaultNetworks, $this->networks);
222
223 7
        if ($requireSubnet) {
224
            $this->allowSubnet = true;
225
        }
226
227 7
        $this->ranges = $this->prepareRanges($ranges);
228
    }
229
230
    /**
231
     * @return array
232
     */
233
    public function getNetworks(): array
234
    {
235
        return $this->networks;
236
    }
237
238
    /**
239
     * @return bool
240
     */
241 117
    public function isAllowIpv4(): bool
242
    {
243 117
        return $this->allowIpv4;
244
    }
245
246
    /**
247
     * @return bool
248
     */
249 35
    public function isAllowIpv6(): bool
250
    {
251 35
        return $this->allowIpv6;
252
    }
253
254
    /**
255
     * @return bool
256
     */
257 34
    public function isAllowSubnet(): bool
258
    {
259 34
        return $this->allowSubnet;
260
    }
261
262
    /**
263
     * @return bool
264
     */
265 52
    public function isRequireSubnet(): bool
266
    {
267 52
        return $this->requireSubnet;
268
    }
269
270
    /**
271
     * @return bool
272
     */
273 8
    public function isAllowNegation(): bool
274
    {
275 8
        return $this->allowNegation;
276
    }
277
278
    /**
279
     * @return string
280
     */
281 116
    public function getMessage(): string
282
    {
283 116
        return $this->message;
284
    }
285
286
    /**
287
     * @return string
288
     */
289 2
    public function getIpv4NotAllowedMessage(): string
290
    {
291 2
        return $this->ipv4NotAllowedMessage;
292
    }
293
294
    /**
295
     * @return string
296
     */
297 1
    public function getIpv6NotAllowedMessage(): string
298
    {
299 1
        return $this->ipv6NotAllowedMessage;
300
    }
301
302
    /**
303
     * @return string
304
     */
305 2
    public function getWrongCidrMessage(): string
306
    {
307 2
        return $this->wrongCidrMessage;
308
    }
309
310
    /**
311
     * @return string
312
     */
313 4
    public function getNoSubnetMessage(): string
314
    {
315 4
        return $this->noSubnetMessage;
316
    }
317
318
    /**
319
     * @return string
320
     */
321 4
    public function getHasSubnetMessage(): string
322
    {
323 4
        return $this->hasSubnetMessage;
324
    }
325
326
    /**
327
     * @return string
328
     */
329 16
    public function getNotInRangeMessage(): string
330
    {
331 16
        return $this->notInRangeMessage;
332
    }
333
334 4
    public function getRanges(): array
335
    {
336 4
        return $this->ranges;
337
    }
338
339
    /**
340
     * Parses IP address/range for the negation with {@see NEGATION_CHAR}.
341
     *
342
     * @param $string
343
     *
344
     * @return array `[0 => bool, 1 => string]`
345
     *  - boolean: whether the string is negated
346
     *  - string: the string without negation (when the negation were present)
347
     */
348 42
    private function parseNegatedRange($string): array
349
    {
350 42
        $isNegated = str_starts_with($string, self::NEGATION_CHAR);
351 42
        return [$isNegated, $isNegated ? substr($string, strlen(self::NEGATION_CHAR)) : $string];
352
    }
353
354
    /**
355
     * Prepares array to fill in {@see $ranges}.
356
     *
357
     *  - Recursively substitutes aliases, described in {@see $networks} with their values,
358
     *  - Removes duplicates.
359
     *
360
     * @see $networks
361
     */
362 7
    private function prepareRanges(array $ranges): array
363
    {
364 7
        $result = [];
365 7
        foreach ($ranges as $string) {
366 4
            [$isRangeNegated, $range] = $this->parseNegatedRange($string);
367 4
            if (isset($this->networks[$range])) {
368 3
                $replacements = $this->prepareRanges($this->networks[$range]);
369 3
                foreach ($replacements as &$replacement) {
370 3
                    [$isReplacementNegated, $replacement] = $this->parseNegatedRange($replacement);
371 3
                    $result[] = ($isRangeNegated && !$isReplacementNegated ? self::NEGATION_CHAR : '') . $replacement;
372
                }
373
            } else {
374 4
                $result[] = $string;
375
            }
376
        }
377
378 7
        return array_unique($result);
379
    }
380
381
    /**
382
     * The method checks whether the IP address with specified CIDR is allowed according to the {@see $ranges} list.
383
     */
384 69
    public function isAllowed(string $ip): bool
385
    {
386 69
        if (empty($this->ranges)) {
387 31
            return true;
388
        }
389
390 38
        foreach ($this->ranges as $string) {
391 38
            [$isNegated, $range] = $this->parseNegatedRange($string);
392 38
            if (IpHelper::inRange($ip, $range)) {
393 32
                return !$isNegated;
394
            }
395
        }
396
397 6
        return false;
398
    }
399
400
    /**
401
     * Used to get the Regexp pattern for initial IP address parsing.
402
     */
403 112
    public function getIpParsePattern(): string
404
    {
405 112
        return '/^(?<not>' . preg_quote(
406
            self::NEGATION_CHAR,
407
            '/'
408
        ) . ')?(?<ipCidr>(?<ip>(?:' . IpHelper::IPV4_PATTERN . ')|(?:' . IpHelper::IPV6_PATTERN . '))(?:\/(?<cidr>-?\d+))?)$/';
409
    }
410
411 7
    public function getOptions(): array
412
    {
413
        return [
414 7
            'networks' => $this->networks,
415 7
            'allowIpv4' => $this->allowIpv4,
416 7
            'allowIpv6' => $this->allowIpv6,
417 7
            'allowSubnet' => $this->allowSubnet,
418 7
            'requireSubnet' => $this->requireSubnet,
419 7
            'allowNegation' => $this->allowNegation,
420
            'message' => [
421 7
                'message' => $this->message,
422
            ],
423
            'ipv4NotAllowedMessage' => [
424 7
                'message' => $this->ipv4NotAllowedMessage,
425
            ],
426
            'ipv6NotAllowedMessage' => [
427 7
                'message' => $this->ipv6NotAllowedMessage,
428
            ],
429
            'wrongCidrMessage' => [
430 7
                'message' => $this->wrongCidrMessage,
431
            ],
432
            'noSubnetMessage' => [
433 7
                'message' => $this->noSubnetMessage,
434
            ],
435
            'hasSubnetMessage' => [
436 7
                'message' => $this->hasSubnetMessage,
437
            ],
438
            'notInRangeMessage' => [
439 7
                'message' => $this->notInRangeMessage,
440
            ],
441 7
            'ranges' => $this->ranges,
442 7
            'skipOnEmpty' => $this->getSkipOnEmptyOption(),
443 7
            'skipOnError' => $this->skipOnError,
444
        ];
445
    }
446
447 1
    public function getHandlerClassName(): string
448
    {
449 1
        return IpHandler::class;
450
    }
451
}
452