Passed
Pull Request — master (#41)
by Alexander
19:40 queued 04:42
created

Ip   B

Complexity

Total Complexity 45

Size/Duplication

Total Lines 421
Duplicated Lines 0 %

Test Coverage

Coverage 90.48%

Importance

Changes 0
Metric Value
eloc 121
dl 0
loc 421
ccs 95
cts 105
cp 0.9048
rs 8.8
c 0
b 0
f 0
wmc 45

17 Methods

Rating   Name   Duplication   Size   Complexity  
A allowSubnet() 0 5 1
A disallowSubnet() 0 5 1
A disallowNegation() 0 4 1
A disallowIpv6() 0 4 1
A allowIpv4() 0 4 1
A allowIpv6() 0 4 1
A disallowIpv4() 0 4 1
A __construct() 0 9 1
A requireSubnet() 0 5 1
A getRanges() 0 3 1
A ranges() 0 4 1
A allowNegation() 0 4 1
A parseNegatedRange() 0 4 2
A getIpParsePattern() 0 3 1
A prepareRanges() 0 17 6
A isAllowed() 0 14 4
D validateValue() 0 64 20

How to fix   Complexity   

Complex Class

Complex classes like Ip often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Ip, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Yiisoft\Validator\Rule;
4
5
use Yiisoft\NetworkUtilities\IpHelper;
6
use Yiisoft\Validator\DataSetInterface;
7
use Yiisoft\Validator\Result;
8
use Yiisoft\Validator\Rule;
9
10
/**
11
 * The validator checks if the attribute value is a valid IPv4/IPv6 address or subnet.
12
 *
13
 * It also may change attribute's value if normalization of IPv6 expansion is enabled.
14
 *
15
 * The following are examples of validation rules using this validator:
16
 *
17
 * ```php
18
 * ['ip_address', 'ip'], // IPv4 or IPv6 address
19
 * ['ip_address', 'ip', 'ipv6' => false], // IPv4 address (IPv6 is disabled)
20
 * ['ip_address', 'ip', 'subnet' => true], // requires a CIDR prefix (like 10.0.0.1/24) for the IP address
21
 * ['ip_address', 'ip', 'subnet' => null], // CIDR prefix is optional
22
 * ['ip_address', 'ip', 'subnet' => null, 'normalize' => true], // CIDR prefix is optional and will be added when missing
23
 * ['ip_address', 'ip', 'ranges' => ['192.168.0.0/24']], // only IP addresses from the specified subnet are allowed
24
 * ['ip_address', 'ip', 'ranges' => ['!192.168.0.0/24', 'any']], // any IP is allowed except IP in the specified subnet
25
 * ['ip_address', 'ip', 'expandIPv6' => true], // expands IPv6 address to a full notation format
26
 * ```
27
 *
28
 * @property array $ranges The IPv4 or IPv6 ranges that are allowed or forbidden. See [[setRanges()]] for
29
 * detailed description.
30
 */
31
class Ip extends Rule
32
{
33
    /**
34
     * Negation char.
35
     *
36
     * Used to negate [[ranges]] or [[networks]] or to negate validating value when [[negation]] is set to `true`.
37
     *
38
     * @see allowNegation
39
     * @see networks
40
     * @see ranges
41
     */
42
    private const NEGATION_CHAR = '!';
43
44
    /**
45
     * @var array The network aliases, that can be used in [[ranges]].
46
     *  - key - alias name
47
     *  - value - array of strings. String can be an IP range, IP address or another alias. String can be
48
     *    negated with [[NEGATION_CHAR]] (independent of `negation` option).
49
     *
50
     * The following aliases are defined by default:
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
     *  - `system`: `multicast, linklocal, localhost, documentation`
59
     */
60
    private $networks = [
61
        '*' => ['any'],
62
        'any' => ['0.0.0.0/0', '::/0'],
63
        'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
64
        'multicast' => ['224.0.0.0/4', 'ff00::/8'],
65
        'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
66
        'localhost' => ['127.0.0.0/8', '::1'],
67
        'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
68
        'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
69
    ];
70
    /**
71
     * @var bool whether the validating value can be an IPv6 address. Defaults to `true`.
72
     */
73
    private $allowIpv6 = true;
74
    /**
75
     * @var bool whether the validating value can be an IPv4 address. Defaults to `true`.
76
     */
77
    private $allowIpv4 = true;
78
    /**
79
     * @var bool whether the address can be an IP with CIDR subnet, like `192.168.10.0/24`.
80
     * The following values are possible:
81
     *
82
     * - `false` - the address must not have a subnet (default).
83
     * - `true` - specifying a subnet is optional.
84
     */
85
    private $allowSubnet = false;
86
    /**
87
     * @var bool
88
     */
89
    private $requireSubnet = false;
90
    /**
91
     * @var bool whether address may have a [[NEGATION_CHAR]] character at the beginning.
92
     * Defaults to `false`.
93
     */
94
    private $allowNegation = false;
95
96
    /**
97
     * @var string user-defined error message is used when validation fails due to the wrong IP address format.
98
     *
99
     * You may use the following placeholders in the message:
100
     *
101
     * - `{attribute}`: the label of the attribute being validated
102
     * - `{value}`: the value of the attribute being validated
103
     */
104
    private $message;
105
    /**
106
     * @var string user-defined error message is used when validation fails due to the disabled IPv6 validation.
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
     * @see allowIpv6
114
     */
115
    private $ipv6NotAllowed;
116
    /**
117
     * @var string user-defined error message is used when validation fails due to the disabled IPv4 validation.
118
     *
119
     * You may use the following placeholders in the message:
120
     *
121
     * - `{attribute}`: the label of the attribute being validated
122
     * - `{value}`: the value of the attribute being validated
123
     *
124
     * @see allowIpv4
125
     */
126
    private $ipv4NotAllowed;
127
    /**
128
     * @var string user-defined error message is used when validation fails due to the wrong CIDR.
129
     *
130
     * You may use the following placeholders in the message:
131
     *
132
     * - `{attribute}`: the label of the attribute being validated
133
     * - `{value}`: the value of the attribute being validated
134
     * @see allowSubnet
135
     */
136
    private $wrongCidr;
137
    /**
138
     * @var string user-defined error message is used when validation fails due to subnet [[subnet]] set to 'only',
139
     * but the CIDR prefix is not set.
140
     *
141
     * You may use the following placeholders in the message:
142
     *
143
     * - `{attribute}`: the label of the attribute being validated
144
     * - `{value}`: the value of the attribute being validated
145
     *
146
     * @see allowSubnet
147
     */
148
    private $noSubnet;
149
    /**
150
     * @var string user-defined error message is used when validation fails
151
     * due to [[subnet]] is false, but CIDR prefix is present.
152
     *
153
     * You may use the following placeholders in the message:
154
     *
155
     * - `{attribute}`: the label of the attribute being validated
156
     * - `{value}`: the value of the attribute being validated
157
     *
158
     * @see allowSubnet
159
     */
160
    private $hasSubnet;
161
    /**
162
     * @var string user-defined error message is used when validation fails due to IP address
163
     * is not not allowed by [[ranges]] check.
164
     *
165
     * You may use the following placeholders in the message:
166
     *
167
     * - `{attribute}`: the label of the attribute being validated
168
     * - `{value}`: the value of the attribute being validated
169
     *
170
     * @see ranges
171
     */
172
    private $notInRange;
173
174
    /**
175
     * @var array
176
     */
177
    private $ranges = [];
178 19
179
    public function __construct()
180 19
    {
181 19
        $this->message = $this->formatMessage('{attribute} must be a valid IP address.');
182 19
        $this->ipv6NotAllowed = $this->formatMessage('{attribute} must not be an IPv6 address.');
183 19
        $this->ipv4NotAllowed = $this->formatMessage('{attribute} must not be an IPv4 address.');
184 19
        $this->wrongCidr = $this->formatMessage('{attribute} contains wrong subnet mask.');
185 19
        $this->noSubnet = $this->formatMessage('{attribute} must be an IP address with specified subnet.');
186 19
        $this->hasSubnet = $this->formatMessage('{attribute} must not be a subnet.');
187
        $this->notInRange = $this->formatMessage('{attribute} is not in the allowed range.');
188
    }
189
190
    /**
191
     * Set the IPv4 or IPv6 ranges that are allowed or forbidden.
192
     *
193
     * The following preparation tasks are performed:
194
     *
195
     * - Recursively substitutes aliases (described in [[networks]]) with their values.
196
     * - Removes duplicates
197
     *
198
     * @param array $ranges the IPv4 or IPv6 ranges that are allowed or forbidden.
199
     *
200
     * When the array is empty, or the option not set, all IP addresses are allowed.
201
     *
202
     * Otherwise, the rules are checked sequentially until the first match is found.
203
     * An IP address is forbidden, when it has not matched any of the rules.
204
     *
205
     * Example:
206
     *
207
     * ```php
208
     * (new Ip())->ranges([
209
     *          '192.168.10.128'
210
     *          '!192.168.10.0/24',
211
     *          'any' // allows any other IP addresses
212
     *      ]);
213
     * ```
214
     *
215
     * In this example, access is allowed for all the IPv4 and IPv6 addresses excluding the `192.168.10.0/24` subnet.
216
     * IPv4 address `192.168.10.128` is also allowed, because it is listed before the restriction.
217
     * @return static
218 8
     */
219
    public function ranges(array $ranges)
220 8
    {
221 8
        $this->ranges = $this->prepareRanges($ranges);
222
        return $this;
223
    }
224 5
225
    public function getRanges(): array
226 5
    {
227
        return $this->ranges;
228
    }
229
230
    /**
231
     * @return static
232 2
     */
233
    public function allowIpv4()
234 2
    {
235 2
        $this->allowIpv4 = true;
236
        return $this;
237
    }
238
239
    /**
240
     * @return static
241 4
     */
242
    public function disallowIpv4()
243 4
    {
244 4
        $this->allowIpv4 = false;
245
        return $this;
246
    }
247
248
    /**
249
     * @return static
250 2
     */
251
    public function allowIpv6()
252 2
    {
253 2
        $this->allowIpv6 = true;
254
        return $this;
255
    }
256
257
    /**
258
     * @return static
259 2
     */
260
    public function disallowIpv6()
261 2
    {
262 2
        $this->allowIpv6 = false;
263
        return $this;
264
    }
265
266
    /**
267
     * @return static
268 3
     */
269
    public function allowNegation()
270 3
    {
271 3
        $this->allowNegation = true;
272
        return $this;
273
    }
274
275
    /**
276
     * @return static
277
     */
278
    public function disallowNegation()
279
    {
280
        $this->allowNegation = false;
281
        return $this;
282
    }
283
284
    /**
285
     * @return static
286 5
     */
287
    public function allowSubnet()
288 5
    {
289 5
        $this->allowSubnet = true;
290 5
        $this->requireSubnet = false;
291
        return $this;
292
    }
293
294
    /**
295
     * @return static
296 3
     */
297
    public function requireSubnet()
298 3
    {
299 3
        $this->allowSubnet = true;
300 3
        $this->requireSubnet = true;
301
        return $this;
302
    }
303
304
    /**
305
     * @return static
306
     */
307
    public function disallowSubnet()
308
    {
309
        $this->allowSubnet = false;
310
        $this->requireSubnet = false;
311
        return $this;
312
    }
313
314 15
315
    protected function validateValue($value, DataSetInterface $dataSet = null): Result
316 15
    {
317 1
        if (!$this->allowIpv4 && !$this->allowIpv6) {
318
            throw new \RuntimeException('Both IPv4 and IPv6 checks can not be disabled at the same time');
319 14
        }
320 14
        $result = new Result();
321 4
        if (!is_string($value)) {
322 4
            $result->addError($this->formatMessage($this->message));
323
            return $result;
324
        }
325 10
326 6
        if (preg_match($this->getIpParsePattern(), $value, $matches) === 0) {
327 6
            $result->addError($this->formatMessage($this->message));
328
            return $result;
329 7
        }
330 7
        $negation = !empty($matches['not'] ?? null);
331 7
        $ip = $matches['ip'];
332 7
        $cidr = $matches['cidr'] ?? null;
333
        $ipCidr = $matches['ipCidr'];
334
335 7
        try {
336
            $ipVersion = IpHelper::getIpVersion($ip, false);
337
        } catch (\InvalidArgumentException $e) {
338
            $result->addError($this->formatMessage($this->message));
339
            return $result;
340
        }
341 7
342 3
        if ($this->requireSubnet === true && $cidr === null) {
343
            $result->addError($this->formatMessage($this->noSubnet));
344 7
            return $result;
345 3
        }
346
        if ($this->allowSubnet === false && $cidr !== null) {
347 7
            $result->addError($this->formatMessage($this->hasSubnet));
348 3
            return $result;
349
        }
350 7
        if ($this->allowNegation === false && $negation) {
351 1
            $result->addError($this->formatMessage($this->message));
352
            return $result;
353 7
        }
354 2
        if ($ipVersion === IpHelper::IPV6 && !$this->allowIpv6) {
355
            $result->addError($this->formatMessage($this->ipv6NotAllowed));
356 7
            return $result;
357 3
        }
358
        if ($ipVersion === IpHelper::IPV4 && !$this->allowIpv4) {
359 7
            $result->addError($this->formatMessage($this->ipv4NotAllowed));
360
            return $result;
361 5
        }
362 2
        if (!$result->isValid()) {
363 2
            return $result;
364 2
        }
365
        if ($cidr !== null) {
366
            try {
367 7
                $cidr = IpHelper::getCidrBits($ipCidr);
0 ignored issues
show
Unused Code introduced by
The assignment to $cidr is dead and can be removed.
Loading history...
368 4
            } catch (\InvalidArgumentException $e) {
369
                $result->addError($this->formatMessage($this->wrongCidr));
370
                return $result;
371 7
            }
372
        }
373
        if (!$this->isAllowed($ipCidr)) {
374
            $result->addError($this->formatMessage($this->notInRange));
375
            return $result;
376
        }
377
378
        return $result;
379 7
    }
380
381 7
    /**
382 3
     * The method checks whether the IP address with specified CIDR is allowed according to the [[ranges]] list.
383
     *
384
     * @see ranges
385 4
     */
386 4
    private function isAllowed(string $ip): bool
387 4
    {
388 4
        if (empty($this->ranges)) {
389
            return true;
390
        }
391
392 3
        foreach ($this->ranges as $string) {
393
            [$isNegated, $range] = $this->parseNegatedRange($string);
394
            if (IpHelper::inRange($ip, $range)) {
395
                return !$isNegated;
396
            }
397
        }
398
399
        return false;
400
    }
401
402
    /**
403 8
     * Parses IP address/range for the negation with [[NEGATION_CHAR]].
404
     *
405 8
     * @param $string
406 8
     * @return array `[0 => bool, 1 => string]`
407
     *  - boolean: whether the string is negated
408
     *  - string: the string without negation (when the negation were present)
409
     */
410
    private function parseNegatedRange($string)
411
    {
412
        $isNegated = strpos($string, static::NEGATION_CHAR) === 0;
413
        return [$isNegated, $isNegated ? substr($string, strlen(static::NEGATION_CHAR)) : $string];
414
    }
415
416
    /**
417
     * Prepares array to fill in [[ranges]].
418
     *
419 8
     *  - Recursively substitutes aliases, described in [[networks]] with their values,
420
     *  - Removes duplicates.
421 8
     *
422 8
     * @param $ranges
423 8
     * @return array
424 8
     * @see networks
425 6
     */
426 6
    private function prepareRanges($ranges)
427 6
    {
428 6
        $result = [];
429
        foreach ($ranges as $string) {
430
            [$isRangeNegated, $range] = $this->parseNegatedRange($string);
431 8
            if (isset($this->networks[$range])) {
432
                $replacements = $this->prepareRanges($this->networks[$range]);
433
                foreach ($replacements as &$replacement) {
434
                    [$isReplacementNegated, $replacement] = $this->parseNegatedRange($replacement);
435 8
                    $result[] = ($isRangeNegated && !$isReplacementNegated ? static::NEGATION_CHAR : '') . $replacement;
436
                }
437
            } else {
438
                $result[] = $string;
439
            }
440
        }
441
442 10
        return array_unique($result);
443
    }
444 10
445
    /**
446
     * Used to get the Regexp pattern for initial IP address parsing.
447
     * @return string
448
     */
449
    public function getIpParsePattern(): string
450
    {
451
        return '/^(?<not>' . preg_quote(static::NEGATION_CHAR, '/') . ')?(?<ipCidr>(?<ip>(?:' . IpHelper::IPV4_PATTERN . ')|(?:' . IpHelper::IPV6_PATTERN . '))(?:\/(?<cidr>-?\d+))?)$/';
452
    }
453
}
454