Test Failed
Pull Request — master (#175)
by
unknown
12:57
created

Ip::validateValue()   D

Complexity

Conditions 20
Paths 15

Size

Total Lines 64
Code Lines 45

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 20.7121

Importance

Changes 0
Metric Value
cc 20
eloc 45
c 0
b 0
f 0
nc 15
nop 2
dl 0
loc 64
ccs 29
cts 33
cp 0.8788
crap 20.7121
rs 4.1666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Validator\Rule;
6
7
use InvalidArgumentException;
8
use RuntimeException;
9
use Yiisoft\NetworkUtilities\IpHelper;
10
use Yiisoft\Validator\FormatterInterface;
11
use Yiisoft\Validator\Result;
12
use Yiisoft\Validator\Rule;
13
use Yiisoft\Validator\ValidationContext;
14
15
use function is_string;
16
use function strlen;
17
18
/**
19
 * The validator checks if a value is a valid IPv4/IPv6 address or subnet.
20
 *
21
 * It also may change a value if normalization of IPv6 expansion is enabled.
22
 *
23
 * The following are examples of validation rules using this validator:
24
 *
25
 * ```php
26
 * ['ip_address', 'ip'], // IPv4 or IPv6 address
27
 * ['ip_address', 'ip', 'ipv6' => false], // IPv4 address (IPv6 is disabled)
28
 * ['ip_address', 'ip', 'subnet' => true], // requires a CIDR prefix (like 10.0.0.1/24) for the IP address
29
 * ['ip_address', 'ip', 'subnet' => null], // CIDR prefix is optional
30
 * ['ip_address', 'ip', 'subnet' => null, 'normalize' => true], // CIDR prefix is optional and will be added when missing
31
 * ['ip_address', 'ip', 'ranges' => ['192.168.0.0/24']], // only IP addresses from the specified subnet are allowed
32
 * ['ip_address', 'ip', 'ranges' => ['!192.168.0.0/24', 'any']], // any IP is allowed except IP in the specified subnet
33
 * ['ip_address', 'ip', 'expandIPv6' => true], // expands IPv6 address to a full notation format
34
 * ```
35
 */
36
final class Ip extends Rule
37
{
38
    /**
39
     * Negation char.
40
     *
41
     * Used to negate {@see $ranges} or {@see $network} or to negate validating value when {@see $allowNegation}
42
     * is used.
43
     */
44
    private const NEGATION_CHAR = '!';
45
46
    public function __construct(
47
        /**
48
         * @var array The network aliases, that can be used in {@see $ranges}.
49
         *  - key - alias name
50
         *  - value - array of strings. String can be an IP range, IP address or another alias. String can be
51
         *    negated with {@see NEGATION_CHAR} (independent of {@see $allowNegation} option).
52
         *
53
         * The following aliases are defined by default:
54
         *  - `*`: `any`
55
         *  - `any`: `0.0.0.0/0, ::/0`
56
         *  - `private`: `10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fd00::/8`
57
         *  - `multicast`: `224.0.0.0/4, ff00::/8`
58
         *  - `linklocal`: `169.254.0.0/16, fe80::/10`
59
         *  - `localhost`: `127.0.0.0/8', ::1`
60
         *  - `documentation`: `192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32`
61
         *  - `system`: `multicast, linklocal, localhost, documentation`
62
         */
63
        private array $networks = [
64
            '*' => ['any'],
65
            'any' => ['0.0.0.0/0', '::/0'],
66
            'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
67
            'multicast' => ['224.0.0.0/4', 'ff00::/8'],
68
            'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
69
            'localhost' => ['127.0.0.0/8', '::1'],
70
            'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
71
            'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
72
        ],
73
        /**
74
         * @var bool whether the validating value can be an IPv4 address. Defaults to `true`.
75
         */
76
        private bool $allowIpv4 = true,
77
        /**
78
         * @var bool whether the validating value can be an IPv6 address. Defaults to `true`.
79
         */
80
        private bool $allowIpv6 = true,
81
        /**
82
         * @var bool 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
         */
88
        private bool $allowSubnet = false,
89
        private bool $requireSubnet = false,
90
        /**
91
         * @var bool whether address may have a {@see NEGATION_CHAR} character at the beginning.
92
         * Defaults to `false`.
93
         */
94
        private bool $allowNegation = false,
95
        /**
96
         * @var string user-defined error message is 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 label of the attribute being validated
101
         * - `{value}`: the value of the attribute being validated
102
         */
103
        private string $message = 'Must be a valid IP address.',
104
        /**
105
         * @var string user-defined error message is used when validation fails due to the disabled IPv4 validation.
106
         *
107
         * You may use the following placeholders in the message:
108
         *
109
         * - `{attribute}`: the label of the attribute being validated
110
         * - `{value}`: the value of the attribute being validated
111
         *
112
         * @see $allowIpv4
113
         */
114
        private string $ipv4NotAllowed = 'Must not be an IPv4 address.',
115
        /**
116
         * @var string user-defined error message is used when validation fails due to the disabled IPv6 validation.
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
         *
123
         * @see $allowIpv6
124
         */
125
        private string $ipv6NotAllowed = 'Must not be an IPv6 address.',
126
        /**
127
         * @var string user-defined error message is used when validation fails due to the wrong CIDR.
128
         *
129
         * You may use the following placeholders in the message:
130
         *
131
         * - `{attribute}`: the label of the attribute being validated
132
         * - `{value}`: the value of the attribute being validated
133
         *
134
         * @see $allowSubnet
135
         */
136
        private string $wrongCidr = 'Contains wrong subnet mask.',
137
        /**
138
         * @var string user-defined error message is used when validation fails due to subnet {@see $allowSubnet} is
139
         * used, 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 string $noSubnet = 'Must be an IP address with specified subnet.',
149
        /**
150
         * @var string user-defined error message is used when validation fails
151
         * due to {@see $allowSubnet} 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 string $hasSubnet = 'Must not be a subnet.',
161
        /**
162
         * @var string user-defined error message is used when validation fails due to IP address
163
         * is not allowed by {@see $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 string $notInRange = 'Is not in the allowed range.',
173
        /**
174
         * @var array The IPv4 or IPv6 ranges that are allowed or forbidden.
175
         *
176
         * The following preparation tasks are performed:
177
         *
178
         * - Recursively substitutes aliases (described in {@see $networks}) with their values.
179
         * - Removes duplicates.
180
         *
181
         * When the array is empty, or the option not set, all IP addresses are allowed.
182
         *
183
         * Otherwise, the rules are checked sequentially until the first match is found.
184
         * An IP address is forbidden, when it has not matched any of the rules.
185
         *
186
         * Example:
187
         *
188
         * ```php
189
         * (new Ip(ranges: [
190
         *     '192.168.10.128'
191
         *     '!192.168.10.0/24',
192
         *     'any' // allows any other IP addresses
193
         * ]);
194 22
         * ```
195
         *
196 22
         * In this example, access is allowed for all the IPv4 and IPv6 addresses excluding the `192.168.10.0/24`
197
         * subnet. IPv4 address `192.168.10.128` is also allowed, because it is listed before the restriction.
198
         */
199 16
        private array $ranges = [],
200
201 16
        ?FormatterInterface $formatter = null,
202 1
        bool $skipOnEmpty = false,
203
        bool $skipOnError = false,
204 15
        $when = null
205 15
    ) {
206 4
        parent::__construct(formatter: $formatter, skipOnEmpty: $skipOnEmpty, skipOnError: $skipOnError, when: $when);
207 4
208
        if ($requireSubnet) {
209
            $this->allowSubnet = true;
210 11
        }
211 6
212 6
        $this->ranges = $this->prepareRanges($ranges);
213
    }
214 8
215 8
    protected function validateValue($value, ?ValidationContext $context = null): Result
216 8
    {
217 8
        if (!$this->allowIpv4 && !$this->allowIpv6) {
218
            throw new RuntimeException('Both IPv4 and IPv6 checks can not be disabled at the same time');
219
        }
220 8
        $result = new Result();
221
        if (!is_string($value)) {
222
            $result->addError($this->formatMessage($this->message));
223
            return $result;
224
        }
225
226 8
        if (preg_match($this->getIpParsePattern(), $value, $matches) === 0) {
227 3
            $result->addError($this->formatMessage($this->message));
228 3
            return $result;
229
        }
230 8
        $negation = !empty($matches['not'] ?? null);
231 3
        $ip = $matches['ip'];
232 3
        $cidr = $matches['cidr'] ?? null;
233
        $ipCidr = $matches['ipCidr'];
234 8
235 3
        try {
236 3
            $ipVersion = IpHelper::getIpVersion($ip, false);
237
        } catch (InvalidArgumentException $e) {
238 8
            $result->addError($this->formatMessage($this->message));
239 1
            return $result;
240 1
        }
241
242 8
        if ($this->requireSubnet === true && $cidr === null) {
243 2
            $result->addError($this->formatMessage($this->noSubnet));
244 2
            return $result;
245
        }
246 8
        if ($this->allowSubnet === false && $cidr !== null) {
247
            $result->addError($this->formatMessage($this->hasSubnet));
248
            return $result;
249 8
        }
250
        if ($this->allowNegation === false && $negation) {
251 5
            $result->addError($this->formatMessage($this->message));
252 2
            return $result;
253 2
        }
254 2
        if ($ipVersion === IpHelper::IPV6 && !$this->allowIpv6) {
255
            $result->addError($this->formatMessage($this->ipv6NotAllowed));
256
            return $result;
257 8
        }
258 4
        if ($ipVersion === IpHelper::IPV4 && !$this->allowIpv4) {
259 4
            $result->addError($this->formatMessage($this->ipv4NotAllowed));
260
            return $result;
261
        }
262 8
        if (!$result->isValid()) {
263
            return $result;
264
        }
265
        if ($cidr !== null) {
266
            try {
267
                IpHelper::getCidrBits($ipCidr);
268
            } catch (InvalidArgumentException $e) {
269
                $result->addError($this->formatMessage($this->wrongCidr));
270
                return $result;
271
            }
272
        }
273
        if (!$this->isAllowed($ipCidr)) {
274
            $result->addError($this->formatMessage($this->notInRange));
275
            return $result;
276
        }
277
278
        return $result;
279
    }
280
281
    /**
282
     * Define network alias to be used in {@see $ranges}
283
     *
284
     * @param string $name name of the network
285
     * @param array $ranges ranges
286
     *
287
     * @return self
288
     */
289
    public function network(string $name, array $ranges): self
290
    {
291
        if (array_key_exists($name, $this->networks)) {
292
            throw new \RuntimeException("Network alias \"{$name}\" already set");
293 10
        }
294
295 10
        $new = clone $this;
296 10
        $new->networks[$name] = $ranges;
297 10
        return $new;
298
    }
299
300
    public function getRanges(): array
301
    {
302
        return $this->ranges;
303
    }
304
305
    /**
306
     * The method checks whether the IP address with specified CIDR is allowed according to the {@see $ranges} list.
307
     */
308 2
    private function isAllowed(string $ip): bool
309
    {
310 2
        if (empty($this->ranges)) {
311 1
            return true;
312
        }
313
314 2
        foreach ($this->ranges as $string) {
315 2
            [$isNegated, $range] = $this->parseNegatedRange($string);
316 2
            if (IpHelper::inRange($ip, $range)) {
317
                return !$isNegated;
318
            }
319 5
        }
320
321 5
        return false;
322
    }
323
324 2
    /**
325
     * Parses IP address/range for the negation with {@see NEGATION_CHAR}.
326 2
     *
327 2
     * @param $string
328 2
     *
329
     * @return array `[0 => bool, 1 => string]`
330
     *  - boolean: whether the string is negated
331 4
     *  - string: the string without negation (when the negation were present)
332
     */
333 4
    private function parseNegatedRange($string): array
334 4
    {
335 4
        $isNegated = strpos($string, self::NEGATION_CHAR) === 0;
336
        return [$isNegated, $isNegated ? substr($string, strlen(self::NEGATION_CHAR)) : $string];
337
    }
338 2
339
    /**
340 2
     * Prepares array to fill in {@see $ranges}.
341 2
     *
342 2
     *  - Recursively substitutes aliases, described in {@see $networks} with their values,
343
     *  - Removes duplicates.
344
     *
345 2
     * @see $networks
346
     */
347 2
    private function prepareRanges(array $ranges): array
348 2
    {
349 2
        $result = [];
350
        foreach ($ranges as $string) {
351
            [$isRangeNegated, $range] = $this->parseNegatedRange($string);
352 3
            if (isset($this->networks[$range])) {
353
                $replacements = $this->prepareRanges($this->networks[$range]);
354 3
                foreach ($replacements as &$replacement) {
355 3
                    [$isReplacementNegated, $replacement] = $this->parseNegatedRange($replacement);
356 3
                    $result[] = ($isRangeNegated && !$isReplacementNegated ? self::NEGATION_CHAR : '') . $replacement;
357
                }
358
            } else {
359
                $result[] = $string;
360
            }
361
        }
362
363
        return array_unique($result);
364
    }
365
366 5
    /**
367
     * Used to get the Regexp pattern for initial IP address parsing.
368 5
     */
369 5
    public function getIpParsePattern(): string
370 5
    {
371 5
        return '/^(?<not>' . preg_quote(
372
            self::NEGATION_CHAR,
373
            '/'
374 3
        ) . ')?(?<ipCidr>(?<ip>(?:' . IpHelper::IPV4_PATTERN . ')|(?:' . IpHelper::IPV6_PATTERN . '))(?:\/(?<cidr>-?\d+))?)$/';
375
    }
376 3
377 3
    public function getOptions(): array
378 3
    {
379 3
        return array_merge(parent::getOptions(), [
380
            'allowIpv4' => $this->allowIpv4,
381
            'allowIpv6' => $this->allowIpv6,
382
            'allowSubnet' => $this->allowSubnet,
383
            'requireSubnet' => $this->requireSubnet,
384
            'allowNegation' => $this->allowNegation,
385
            'message' => $this->formatMessage($this->message),
386
            'ipv4NotAllowedMessage' => $this->formatMessage($this->ipv4NotAllowed),
387
            'ipv6NotAllowedMessage' => $this->formatMessage($this->ipv6NotAllowed),
388
            'wrongCidrMessage' => $this->formatMessage($this->wrongCidr),
389
            'noSubnetMessage' => $this->formatMessage($this->noSubnet),
390
            'hasSubnetMessage' => $this->formatMessage($this->hasSubnet),
391
            'notInRangeMessage' => $this->formatMessage($this->notInRange),
392
            'ranges' => $this->ranges,
393 8
        ]);
394
    }
395
}
396