PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.4
2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 All 87 releases
vigilante / includes / class-ip-utils.php

class-ip-utils.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.4, at includes/class-ip-utils.php

430 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * IP matching utilities
4 *
5 * Shared IP/pattern matching for the firewall and login modules. Supports
6 * exact addresses, CIDR ranges and wildcards, for both IPv4 and IPv6.
7 *
8 * @package Vigilante
9 */
10
11 // Prevent direct access
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Class Vigilante_IP_Utils
18 *
19 * Stateless helpers. All methods are static.
20 */
21 class Vigilante_IP_Utils {
22
23 /**
24 * Check whether an IP matches a single pattern.
25 *
26 * Supported pattern forms (IPv4 and IPv6 alike):
27 * - Exact address: 203.0.113.5 / 2a02:c207::1
28 * - CIDR range: 203.0.113.0/24 / 2a02:c207::/32
29 * - Wildcard: 203.0.113.* / 2a02:c207:*
30 *
31 * @param string $ip IP address to test.
32 * @param string $pattern Pattern to match against.
33 * @return bool True on match.
34 */
35 public static function matches( $ip, $pattern ) {
36 $ip = trim( (string) $ip );
37 $pattern = trim( (string) $pattern );
38
39 if ( '' === $ip || '' === $pattern ) {
40 return false;
41 }
42
43 // Exact match (also covers fully-written IPv6).
44 if ( $ip === $pattern ) {
45 return true;
46 }
47
48 // CIDR notation.
49 if ( false !== strpos( $pattern, '/' ) ) {
50 return self::cidr_match( $ip, $pattern );
51 }
52
53 // Wildcard notation.
54 if ( false !== strpos( $pattern, '*' ) ) {
55 return self::wildcard_match( $ip, $pattern );
56 }
57
58 return false;
59 }
60
61 /**
62 * Check whether an IP matches any pattern in a list.
63 *
64 * @param string $ip IP address to test.
65 * @param array $list List of patterns.
66 * @return bool True if any pattern matches.
67 */
68 public static function in_list( $ip, $list ) {
69 if ( empty( $list ) || ! is_array( $list ) ) {
70 return false;
71 }
72
73 foreach ( $list as $pattern ) {
74 if ( self::matches( $ip, (string) $pattern ) ) {
75 return true;
76 }
77 }
78
79 return false;
80 }
81
82 /**
83 * Whether a string is a pattern this class can actually match.
84 *
85 * The counterpart of matches(): everything this returns true for is
86 * something the matcher understands, and everything else is noise that
87 * would sit in an IP list looking effective while matching nothing. Kept
88 * next to the matcher on purpose, so validation and matching cannot drift
89 * apart again.
90 *
91 * @since 2.9.9
92 *
93 * @param string $pattern Candidate pattern.
94 * @return bool
95 */
96 public static function is_valid_pattern( $pattern ) {
97 $pattern = trim( (string) $pattern );
98
99 if ( '' === $pattern ) {
100 return false;
101 }
102
103 // Exact address, IPv4 or IPv6.
104 if ( filter_var( $pattern, FILTER_VALIDATE_IP ) ) {
105 return true;
106 }
107
108 // CIDR range: same criterion cidr_match() applies, family included.
109 if ( false !== strpos( $pattern, '/' ) ) {
110 $parts = explode( '/', $pattern, 2 );
111 if ( 2 !== count( $parts ) ) {
112 return false;
113 }
114
115 $subnet = trim( $parts[0] );
116 $bits = trim( $parts[1] );
117
118 if ( '' === $bits || ! ctype_digit( $bits ) ) {
119 return false;
120 }
121
122 if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
123 return false;
124 }
125
126 $packed = inet_pton( $subnet );
127 if ( false === $packed ) {
128 return false;
129 }
130
131 return ( (int) $bits <= strlen( $packed ) * 8 );
132 }
133
134 // Wildcard: what is left once the asterisks are gone has to be a
135 // plausible prefix, of one family only.
136 if ( false !== strpos( $pattern, '*' ) ) {
137 return self::is_valid_wildcard( $pattern );
138 }
139
140 return false;
141 }
142
143 /**
144 * Split a list into the patterns that can match and the ones that cannot.
145 *
146 * @since 2.9.9
147 *
148 * @param array|string $list List of patterns, or a newline separated string.
149 * @return array{valid: string[], rejected: string[]}
150 */
151 public static function split_list( $list ) {
152 if ( is_string( $list ) ) {
153 $list = preg_split( '/[\r\n]+/', $list );
154 }
155
156 $valid = array();
157 $rejected = array();
158
159 foreach ( (array) $list as $entry ) {
160 $entry = trim( (string) $entry );
161
162 if ( '' === $entry ) {
163 continue;
164 }
165
166 if ( self::is_valid_pattern( $entry ) ) {
167 $valid[] = $entry;
168 } else {
169 $rejected[] = $entry;
170 }
171 }
172
173 return array(
174 'valid' => array_values( array_unique( $valid ) ),
175 'rejected' => array_values( array_unique( $rejected ) ),
176 );
177 }
178
179 /**
180 * Whether a wildcard pattern is plausible for one address family.
181 *
182 * A bare '*' is rejected on purpose: as a whitelist it would let everyone
183 * in and as a blacklist it would lock everyone out, and nobody types that
184 * meaning to.
185 *
186 * @since 2.9.9
187 *
188 * @param string $pattern Wildcard pattern.
189 * @return bool
190 */
191 private static function is_valid_wildcard( $pattern ) {
192 $bare = str_replace( '*', '', $pattern );
193
194 if ( '' === $bare || '.' === $bare || ':' === $bare ) {
195 return false;
196 }
197
198 // IPv6 when there is a colon, IPv4 otherwise. The two never mix.
199 if ( false !== strpos( $pattern, ':' ) ) {
200 if ( ! preg_match( '/^[0-9A-Fa-f:*]+$/', $pattern ) ) {
201 return false;
202 }
203
204 $groups = explode( ':', $pattern );
205 if ( count( $groups ) > 8 ) {
206 return false;
207 }
208
209 foreach ( $groups as $group ) {
210 if ( '' === $group || '*' === $group ) {
211 continue;
212 }
213 if ( ! preg_match( '/^[0-9A-Fa-f]{1,4}\*?$/', $group ) ) {
214 return false;
215 }
216 }
217
218 return true;
219 }
220
221 if ( ! preg_match( '/^[0-9.*]+$/', $pattern ) ) {
222 return false;
223 }
224
225 $octets = explode( '.', $pattern );
226 if ( count( $octets ) > 4 ) {
227 return false;
228 }
229
230 foreach ( $octets as $octet ) {
231 if ( '' === $octet || '*' === $octet ) {
232 continue;
233 }
234 // A partial octet such as 2* is a prefix, so it is not range checked.
235 if ( ! preg_match( '/^[0-9]{1,3}\*?$/', $octet ) ) {
236 return false;
237 }
238 if ( '*' !== substr( $octet, -1 ) && (int) $octet > 255 ) {
239 return false;
240 }
241 }
242
243 return true;
244 }
245
246 /**
247 * Match an IP against a CIDR range. Works for IPv4 and IPv6.
248 *
249 * The comparison is done on the packed binary form, so the textual
250 * representation of an IPv6 address (compressed or not) does not matter.
251 *
252 * @param string $ip IP address to test.
253 * @param string $cidr CIDR range (e.g. 203.0.113.0/24 or 2a02::/32).
254 * @return bool True on match.
255 */
256 private static function cidr_match( $ip, $cidr ) {
257 $parts = explode( '/', $cidr, 2 );
258 if ( 2 !== count( $parts ) ) {
259 return false;
260 }
261
262 $subnet = trim( $parts[0] );
263 $bits = trim( $parts[1] );
264
265 // Prefix length must be a plain integer.
266 if ( '' === $bits || ! ctype_digit( $bits ) ) {
267 return false;
268 }
269 $bits = (int) $bits;
270
271 // Validate both addresses before packing so inet_pton never warns.
272 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) || ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
273 return false;
274 }
275
276 $ip_packed = inet_pton( $ip );
277 $subnet_packed = inet_pton( $subnet );
278 if ( false === $ip_packed || false === $subnet_packed ) {
279 return false;
280 }
281
282 // Different address family (4 bytes for IPv4, 16 for IPv6).
283 if ( strlen( $ip_packed ) !== strlen( $subnet_packed ) ) {
284 return false;
285 }
286
287 $max_bits = strlen( $ip_packed ) * 8;
288 if ( $bits < 0 || $bits > $max_bits ) {
289 return false;
290 }
291
292 // Compare whole bytes first.
293 $whole_bytes = intdiv( $bits, 8 );
294 if ( $whole_bytes > 0 && substr( $ip_packed, 0, $whole_bytes ) !== substr( $subnet_packed, 0, $whole_bytes ) ) {
295 return false;
296 }
297
298 // Then the remaining bits of the partial byte, if any.
299 $remaining = $bits % 8;
300 if ( $remaining > 0 ) {
301 $mask = 0xFF << ( 8 - $remaining ) & 0xFF;
302 $ip_byte = ord( $ip_packed[ $whole_bytes ] );
303 $sub_byte = ord( $subnet_packed[ $whole_bytes ] );
304 if ( ( $ip_byte & $mask ) !== ( $sub_byte & $mask ) ) {
305 return false;
306 }
307 }
308
309 return true;
310 }
311
312 /**
313 * Match an IP against a wildcard pattern (e.g. 203.0.113.* or 2a02:c207:*).
314 *
315 * Operates on the textual form. The '*' stands for any run of characters;
316 * every other character is matched literally, so it works for the dots of
317 * IPv4 and the colons of IPv6.
318 *
319 * @param string $ip IP address to test.
320 * @param string $pattern Wildcard pattern.
321 * @return bool True on match.
322 */
323 private static function wildcard_match( $ip, $pattern ) {
324 $quoted = preg_quote( $pattern, '/' );
325 $regex = '/^' . str_replace( '\*', '.*', $quoted ) . '$/';
326
327 return (bool) preg_match( $regex, $ip );
328 }
329
330 /**
331 * Proxy headers an admin may declare as trusted, mapped to their $_SERVER key.
332 *
333 * @return array<string,string>
334 */
335 public static function trusted_header_map() {
336 return array(
337 'cf-connecting-ip' => 'HTTP_CF_CONNECTING_IP',
338 'x-forwarded-for' => 'HTTP_X_FORWARDED_FOR',
339 'x-real-ip' => 'HTTP_X_REAL_IP',
340 );
341 }
342
343 /**
344 * The proxy header the admin has declared as trusted, or '' for none.
345 *
346 * @return string
347 */
348 public static function trusted_proxy_header() {
349 $options = get_option( 'vigilante_options' );
350 if ( is_array( $options ) && ! empty( $options['firewall']['trusted_proxy_header'] ) ) {
351 $header = (string) $options['firewall']['trusted_proxy_header'];
352 if ( isset( self::trusted_header_map()[ $header ] ) ) {
353 return $header;
354 }
355 }
356 return '';
357 }
358
359 /**
360 * Resolve the client IP from a $_SERVER-like array.
361 *
362 * Only the real TCP peer (REMOTE_ADDR) is trusted by default, because it
363 * cannot be spoofed. A forwarded-for / connecting-ip header is honoured
364 * ONLY when the admin has explicitly declared their site sits behind that
365 * proxy; otherwise any visitor could forge the header and impersonate any
366 * IP (bypassing the whitelist, evading the blacklist, poisoning the rate
367 * limiter, etc.).
368 *
369 * @param array $server A $_SERVER-like array.
370 * @param string $trusted_header One of the keys in trusted_header_map(), or '' for none.
371 * @return string Validated IP, or '0.0.0.0' when none could be determined.
372 */
373 public static function resolve_client_ip( $server, $trusted_header = '' ) {
374 $map = self::trusted_header_map();
375
376 if ( '' !== $trusted_header && isset( $map[ $trusted_header ] ) ) {
377 $key = $map[ $trusted_header ];
378 if ( ! empty( $server[ $key ] ) ) {
379 $value = (string) $server[ $key ];
380 // X-Forwarded-For may be a "client, proxy1, proxy2" chain; the
381 // original client is the first entry.
382 if ( false !== strpos( $value, ',' ) ) {
383 $parts = explode( ',', $value );
384 $value = $parts[0];
385 }
386 $value = trim( $value );
387 if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
388 return $value;
389 }
390 }
391 }
392
393 if ( ! empty( $server['REMOTE_ADDR'] ) ) {
394 $remote = trim( (string) $server['REMOTE_ADDR'] );
395 if ( filter_var( $remote, FILTER_VALIDATE_IP ) ) {
396 return $remote;
397 }
398 }
399
400 return '0.0.0.0';
401 }
402
403 /**
404 * Current request client IP, honouring the configured trusted proxy header.
405 *
406 * Reads only the needed headers, each sanitized at the point of access, so
407 * the input-sanitization sniff is satisfied without any suppression.
408 *
409 * @return string
410 */
411 public static function get_client_ip() {
412 $trusted = self::trusted_proxy_header();
413 $server = array();
414
415 if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
416 $server['REMOTE_ADDR'] = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
417 }
418
419 if ( '' !== $trusted ) {
420 $map = self::trusted_header_map();
421 $key = $map[ $trusted ];
422 if ( isset( $_SERVER[ $key ] ) ) {
423 $server[ $key ] = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
424 }
425 }
426
427 return self::resolve_client_ip( $server, $trusted );
428 }
429 }
430