PluginProbe
Patchstack – WordPress & Plugins Security / 2.1.2
Patchstack – WordPress & Plugins Security v2.1.2
2.3.7 trunk 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.17 2.1.18 2.1.19 2.1.2 2.1.20 2.1.21 2.1.22 2.1.23 2.1.24 2.1.25 2.1.3 2.1.4 2.1.5 2.1.6 All 49 releases
patchstack / includes / firewall.php

firewall.php in Patchstack – WordPress & Plugins Security 2.1.2, at includes/firewall.php

728 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // Do not allow the file to be called directly.
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 /**
9 * This class provides the firewall functionality.
10 */
11 class P_Firewall extends P_Core {
12
13 /**
14 * Parse the firewall and whitelist rules and determine if it's valid.
15 * Then set the types with all server/client variables and launch the processor.
16 *
17 * @param bool $from_main Whether or not the firewall is loaded from the main script or not.
18 * @param Patchstack $core
19 * @param bool $skip Whether or not to process and execute the rules.
20 * @return void
21 */
22 public function __construct( $from_main = false, $core = null, $skip = false ) {
23 if ( ! $from_main || ! $core ) {
24 if ( $core ) {
25 parent::__construct( $core );
26 }
27 return;
28 }
29
30 parent::__construct( $core );
31
32 // If we only want to initialize the firewall but not execute the rules.
33 if ( $skip ) {
34 return;
35 }
36
37 // Process the firewall rules.
38 $this->processor();
39 }
40
41 /**
42 * Check the custom whitelist rules defined in the backend of WordPress
43 * and attempt to match it with the request.
44 *
45 * @return boolean
46 */
47 private function is_custom_whitelisted() {
48 $whitelist = str_replace( '<?php exit; ?>', '', get_option( 'patchstack_custom_whitelist_rules', '' ) );
49 if ( empty( $whitelist ) ) {
50 return false;
51 }
52
53 // Loop through all lines.
54 $lines = explode( "\n", $whitelist );
55 $ip = $this->get_ip();
56
57 foreach ( $lines as $line ) {
58 $t = explode( ':', $line );
59
60 if ( count( $t ) == 2 ) {
61 $val = strtolower( trim( $t[1] ) );
62 switch ( strtolower( $t[0] ) ) {
63 // IP address match.
64 case 'ip':
65 if ( $ip == $val ) {
66 return true;
67 }
68 break;
69 // Payload match.
70 case 'payload':
71 if ( count( $_POST ) > 0 && strpos( strtolower( print_r( $_POST, true ) ), $val ) !== false ) {
72 return true;
73 }
74
75 if ( count( $_GET ) > 0 && strpos( strtolower( print_r( $_GET, true ) ), $val ) !== false ) {
76 return true;
77 }
78 break;
79 // URL match.
80 case 'url':
81 if ( strpos( strtolower( $_SERVER['REQUEST_URI'] ), $val ) !== false ) {
82 return true;
83 }
84 break;
85 }
86 }
87 }
88
89 return false;
90 }
91
92 /**
93 * Determine if the request should be whitelisted.
94 *
95 * @return boolean
96 */
97 private function is_whitelisted() {
98 // First check if the user has custom whitelist rules configured.
99 if ( $this->is_custom_whitelisted() ) {
100 return true;
101 }
102
103 // Load the whitelist.
104 $whitelists = get_option( 'patchstack_whitelist_rules', '' );
105 if ( $whitelists == null || $whitelists == '' ) {
106 return false;
107 }
108
109 // Parse the whitelist.
110 $whitelists = json_decode( str_replace( '<?php exit; ?>', '', $whitelists ), true );
111
112 // Grab visitor's IP address and request data.
113 $client_ip = $this->get_ip();
114 $requests = $this->capture_request();
115
116 foreach ( $whitelists as $whitelist ) {
117 $whitelist_rule = json_decode( $whitelist['rule'] );
118 $matched_rules = 0;
119
120 // If matches on all request methods, only 1 rule match is required to whitelist.
121 if ( $whitelist_rule->method === 'ALL' ) {
122 $count_rules = 1;
123 } else {
124 if ( ! is_null( $whitelist_rule ) ) {
125 $count_rules = $whitelist_rule->rules;
126 $count_rules = $this->count_rules( $count_rules );
127 }
128 }
129
130 // If an IP address match is given, determine if it matches.
131 $ip = isset( $whitelist_rule->rules, $whitelist_rule->rules->ip_address ) ? $whitelist_rule->rules->ip_address : null;
132 if ( ! is_null( $ip ) ) {
133 if ( strpos( $ip, '*' ) !== false ) {
134 $whitelisted_ip = $this->plugin->ban->check_wildcard_rule( $client_ip, $ip );
135 } elseif ( strpos( $ip, '-' ) !== false ) {
136 $whitelisted_ip = $this->plugin->ban->check_range_rule( $client_ip, $ip );
137 } elseif ( strpos( $ip, '/' ) !== false ) {
138 $whitelisted_ip = $this->plugin->ban->check_subnet_mask_rule( $client_ip, $ip );
139 } elseif ( $client_ip == $ip ) {
140 $whitelisted_ip = true;
141 } else {
142 $whitelisted_ip = false;
143 }
144 } else {
145 $whitelisted_ip = true;
146 }
147
148 foreach ( $requests as $key => $request ) {
149 if ( $whitelist_rule->method == $requests['method'] || $whitelist_rule->method == 'ALL' ) {
150 $test = strtolower( preg_replace( '/(?!^)[A-Z]{2,}(?=[A-Z][a-z])|[A-Z][a-z]/', '->$0', $key ) );
151 $rule = array_reduce(
152 explode( '->', $test ),
153 function ( $o, $p ) {
154 return $o->$p;
155 },
156 $whitelist_rule
157 );
158
159 if ( ! is_null( $rule ) && substr( $key, 0, 4 ) == 'rule' && $this->is_rule_match( $rule, $request ) ) {
160 $matched_rules++;
161 }
162 }
163 }
164
165 if ( $matched_rules >= $count_rules && $whitelisted_ip ) {
166 return true;
167 }
168 }
169
170 return false;
171 }
172
173 /**
174 * Retrieve all HTTP headers that start with HTTP_.
175 *
176 * @return array
177 */
178 private function get_headers() {
179 $headers = array();
180 foreach ( $_SERVER as $name => $value ) {
181 if ( substr( $name, 0, 5 ) == 'HTTP_' ) {
182 $headers[ str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', substr( $name, 5 ) ) ) ) ) ] = $value;
183 }
184 }
185
186 return $headers;
187 }
188
189 /**
190 * Retrieve information about any file uploads.
191 *
192 * @return array
193 */
194 private function get_file_upload_data() {
195 if ( ! is_array( $_FILES ) || count( $_FILES ) == 0 ) {
196 return '';
197 }
198
199 // Extract the information we need from $_FILES.
200 $return = array();
201 foreach ( $_FILES as $key => $data ) {
202 foreach ( $data as $key2 => $data2 ) {
203
204 // We only want the name and type.
205 if ( ! in_array( $key2, array( 'name', 'type' ) ) ) {
206 continue;
207 }
208
209 if ( ! is_array( $data2 ) ) {
210 $return[] = $key2 . '=' . $data2;
211 } else {
212 $return[] = $key2 . '=' . @$this->multi_implode( $data2, '&' . $key2 . '=' );
213 }
214 }
215 }
216
217 return implode( '&', $return );
218 }
219
220
221 /**
222 * Returns all request methods and parameters
223 *
224 * @return string
225 */
226 private function capture_request() {
227 $data = $this->capture_keys();
228
229 // Get the method and URL.
230 $method = $_SERVER['REQUEST_METHOD'];
231 $rulesUri = $_SERVER['REQUEST_URI'];
232
233 // Store the header values in different formats.
234 $rulesHeadersKeys = array();
235 $rulesHeadersValues = array();
236 $rulesHeadersCombinations = array();
237
238 // Retrieve the headers.
239 $headers = $this->get_headers();
240 $rulesHeadersAll = implode( ' ', $headers );
241 foreach ( $headers as $name => $value ) {
242 $rulesHeadersKeys[] = $name;
243 $rulesHeadersValues[] = $value;
244 $rulesHeadersCombinations[] = $name . ': ' . $value;
245 }
246
247 // Store the $_POST values in different formats.
248 $rulesBodyKeys = array();
249 $rulesBodyValues = array();
250 $rulesBodyCombinations = array();
251
252 // Retrieve the $_POST values.
253 $rulesBodyAll = urldecode( http_build_query( $data['POST'] ) );
254 foreach ( $data['POST'] as $key => $value ) {
255 if ( is_array( $value ) ) {
256 $value = @$this->multi_implode( $value, ' ' );
257 }
258 $rulesBodyKeys[] = $key;
259 $rulesBodyValues[] = $value;
260 $rulesBodyCombinations[] = $key . '=' . $value;
261 }
262
263 // Store the $_GET values in different formats.
264 $rulesParamsKeys = array();
265 $rulesParamsValues = array();
266 $rulesParamsCombinations = array();
267
268 // Retrieve the $_GET values.
269 $rulesParamsAll = urldecode( http_build_query( $data['GET'] ) );
270 foreach ( $data['GET'] as $key => $value ) {
271 if ( is_array( $value ) ) {
272 $value = @$this->multi_implode( $value, ' ' );
273 }
274 $rulesParamsKeys[] = $key;
275 $rulesParamsValues[] = $value;
276 $rulesParamsCombinations[] = $key . '=' . $value;
277 }
278
279 // Raw POST data.
280 $rulesRawPost = @file_get_contents( 'php://input' );
281
282 // Data about file uploads.
283 $rulesFile = $this->get_file_upload_data();
284
285 // Return each value as its own array.
286 return compact(
287 'method',
288 'rulesFile',
289 'rulesRawPost',
290 'rulesUri',
291 'rulesHeadersAll',
292 'rulesHeadersKeys',
293 'rulesHeadersValues',
294 'rulesHeadersCombinations',
295 'rulesBodyAll',
296 'rulesBodyKeys',
297 'rulesBodyValues',
298 'rulesBodyCombinations',
299 'rulesParamsAll',
300 'rulesParamsKeys',
301 'rulesParamsValues',
302 'rulesParamsCombinations'
303 );
304 }
305
306 /**
307 * Capture the keys of the request.
308 *
309 * @return array
310 */
311 private function capture_keys() {
312 // Data we want to go through.
313 $data = array(
314 'POST' => $_POST,
315 'GET' => $_GET,
316 );
317
318 // Determine if there are any keys we should remove from the data set.
319 if ( get_option( 'patchstack_whitelist_keys_rules', '' ) == '' ) {
320 return $data;
321 }
322
323 // Must be valid JSON and decodes to at least 2 primary data arrays.
324 $keys = json_decode( get_option( 'patchstack_whitelist_keys_rules' ), true );
325 if ( ! $keys || ! is_array( $keys ) || $keys && count( $keys ) < 2 ) {
326 return $data;
327 }
328
329 // Remove the keys where necessary, go through all data types (GET, POST).
330 foreach ( $keys as $type => $entries ) {
331
332 // Go through all whitelisted actions.
333 foreach ( $entries as $entry ) {
334 $t = explode( '.', $entry );
335
336 // For non-multidimensional array checks.
337 if ( count( $t ) == 1 ) {
338 // If the value itself exists.
339 if ( isset( $data[ $type ][ $t[0] ] ) ) {
340 unset( $data[ $type ][ $t[0] ] );
341 }
342
343 // For pattern checking.
344 if ( strpos( $t[0], '*' ) !== false ) {
345 $star = explode( '*', $t[0] );
346
347 // Loop through all $_POST, $_GET values.
348 foreach ( $data as $method => $values ) {
349 foreach ( $values as $key => $value ) {
350 if ( ! is_array( $value ) && strpos( $key, $star[0] ) !== false ) {
351 unset( $data[ $method ][ $key ] );
352 }
353 }
354 }
355 }
356 continue;
357 }
358
359 // For multidimensional array checks.
360 $end =& $data[ $type ];
361 $skip = false;
362 foreach ( $t as $var ) {
363 if ( ! isset( $end[ $var ] ) ) {
364 $skip = true;
365 break;
366 }
367 $end =& $end[ $var ];
368 }
369
370 // Since we cannot unset it due to it being a reference variable,
371 // we just set it to an empty string instead.
372 if ( ! $skip ) {
373 $end = '';
374 }
375 }
376 }
377
378 return $data;
379 }
380
381 /**
382 * Implode array recursively.
383 *
384 * @param $array
385 * @param $glue
386 * @return bool|string
387 */
388 private function multi_implode( $array, $glue ) {
389 $ret = '';
390
391 foreach ( $array as $item ) {
392 if ( is_array( $item ) ) {
393 $ret .= $this->multi_implode( $item, $glue ) . $glue;
394 } else {
395 $ret .= $item . $glue;
396 }
397 }
398
399 return substr( $ret, 0, 0 - strlen( $glue ) );
400 }
401
402 /**
403 * Determine if the request matches the given firewall or whitelist rule.
404 *
405 * @param string $rule
406 * @param string|array $request
407 * @return bool
408 */
409 private function is_rule_match( $rule, $request ) {
410 $is_matched = false;
411 if ( is_array( $request ) ) {
412 foreach ( $request as $key => $value ) {
413 $is_matched = $this->is_rule_match( $rule, $value );
414 if ( $is_matched ) {
415 return $is_matched;
416 }
417 }
418 } else {
419 return preg_match( $rule, urldecode( $request ) );
420 }
421
422 return $is_matched;
423 }
424
425 /**
426 * Count the number of rules.
427 *
428 * @param array $array
429 * @return integer
430 */
431 private function count_rules( $array ) {
432 $counter = 0;
433 if ( is_object( $array ) ) {
434 $array = (array) $array;
435 }
436
437 if ( $array['uri'] ) {
438 $counter++;
439 }
440
441 foreach ( array( 'body', 'params', 'headers' ) as $type ) {
442 foreach ( $array[ $type ] as $key => $value ) {
443 if ( ! is_null( $value ) ) {
444 $counter++;
445 }
446 }
447 }
448
449 return $counter;
450 }
451
452 /**
453 * Runs the firewall rules processor.
454 *
455 * @return void
456 */
457 private function processor() {
458 // Load the firewall rules.
459 $rules = json_decode( get_option( 'patchstack_firewall_rules', '' ), true );
460 if ( $rules == '' || is_null( $rules ) ) {
461 return;
462 }
463
464 // Determine if the user is temporarily blocked from the site.
465 if ( $this->is_auto_ip_blocked() > $this->get_option( 'patchstack_autoblock_attempts', 10 ) && ! $this->is_authenticated() ) {
466 $this->display_error_page( 22 );
467 }
468
469 // Check for whitelist.
470 $is_whitelisted = $this->is_whitelisted();
471
472 // Obtain the IP address and request data.
473 $client_ip = $this->get_ip();
474 $requests = $this->capture_request();
475
476 // Iterate through all root objects.
477 foreach ( $rules as $firewall_rule ) {
478 $blocked_count = 0;
479 $firewall_rule['bypass_whitelist'] = isset( $firewall_rule['bypass_whitelist'] ) ? $firewall_rule['bypass_whitelist'] : false;
480
481 // Do we need to skip the whitelist for a particular rule?
482 if ( isset( $firewall_rule['bypass_whitelist'] ) && ! $firewall_rule['bypass_whitelist'] && $is_whitelisted ) {
483 continue;
484 }
485
486 $rule_terms = json_decode( $firewall_rule['rule'] );
487
488 // Determine if we should match the IP address.
489 $ip = isset( $rule_terms->rules->ip_address ) ? $rule_terms->rules->ip_address : null;
490 if ( ! is_null( $ip ) ) {
491 $matched_ip = false;
492 if ( strpos( $ip, '*' ) !== false ) {
493 $matched_ip = $this->plugin->ban->check_wildcard_rule( $client_ip, $ip );
494 } elseif ( strpos( $ip, '-' ) !== false ) {
495 $matched_ip = $this->plugin->ban->check_range_rule( $client_ip, $ip );
496 } elseif ( strpos( $ip, '/' ) !== false ) {
497 $matched_ip = $this->plugin->ban->check_subnet_mask_rule( $client_ip, $ip );
498 } elseif ( $client_ip == $ip ) {
499 $matched_ip = true;
500 }
501
502 if ( ! $matched_ip ) {
503 continue;
504 }
505 }
506
507 // If matches on all request methods, only 1 rule match is required to block
508 if ( $rule_terms->method === 'ALL' ) {
509 $count_rules = 1;
510 } else {
511 $count_rules = json_decode( json_encode( $rule_terms->rules ), true );
512 $count_rules = $this->count_rules( $count_rules );
513 }
514
515 // Loop through all request data that we captured.
516 foreach ( $requests as $key => $request ) {
517
518 // Treat the raw POST data string as the body contents of all values combined.
519 if ( $key == 'rulesRawPost' ) {
520 $key = 'rulesBodyAll';
521 }
522
523 // Determine if the requesting method matches.
524 if ( $rule_terms->method == $requests['method'] || $rule_terms->method == 'ALL' || $rule_terms->method == 'GET' || ( $rule_terms->method == 'FILES' && $this->is_file_upload() ) ) {
525 $test = strtolower( preg_replace( '/(?!^)[A-Z]{2,}(?=[A-Z][a-z])|[A-Z][a-z]/', '->$0', $key ) );
526 $exp = explode( '->', $test );
527
528 // Determine if a rule exists for this request.
529 $rule = array_reduce(
530 $exp,
531 function ( $o, $p ) {
532 return $o->$p;
533 },
534 $rule_terms
535 );
536
537 // Determine if the rule matches the request.
538 if ( ! is_null( $rule ) && substr( $key, 0, 4 ) == 'rule' && $this->is_rule_match( $rule, $request ) ) {
539 $blocked_count++;
540 }
541 }
542 }
543
544 // Determine if the user should be blocked.
545 if ( $blocked_count >= $count_rules ) {
546 if ( $rule_terms->type == 'BLOCK' ) {
547 $this->block_user( $firewall_rule['id'], (bool) $firewall_rule['bypass_whitelist'] );
548 } elseif ( $rule_terms->type == 'LOG' ) {
549 $this->log_user( $firewall_rule['id'] );
550 } elseif ( $rule_terms->type == 'REDIRECT' ) {
551 $this->redirect_user( $firewall_rule['id'], $rule_terms->type_params );
552 }
553 }
554 }
555 }
556
557 /**
558 * Determine if the current request is a file upload.
559 *
560 * @return boolean
561 */
562 private function is_file_upload() {
563 return isset( $_FILES ) && count( $_FILES ) > 0;
564 }
565
566 /**
567 * Automatically block the user if there are many blocked requests in a short period of time.
568 *
569 * @return integer
570 */
571 public function is_auto_ip_blocked() {
572 // Calculate block time.
573 $minutes = (int) $this->get_option( 'patchstack_autoblock_minutes', 30 );
574 $timeout = (int) $this->get_option( 'patchstack_autoblock_blocktime', 60 );
575 if ( empty( $minutes ) || empty( $timeout ) ) {
576 $time = 30 + 60;
577 } else {
578 $time = $minutes + $timeout;
579 }
580
581 // Determine if the user should be blocked.
582 global $wpdb;
583 $results = $wpdb->get_results(
584 $wpdb->prepare( 'SELECT COUNT(*) as numIps FROM ' . $wpdb->prefix . "patchstack_firewall_log WHERE block_type = 'BLOCK' AND apply_ban = 1 AND ip = '%s' AND log_date >= ('" . current_time( 'mysql' ) . "' - INTERVAL %d MINUTE)", array( $this->get_ip(), $time ) ),
585 OBJECT
586 );
587
588 if ( ! isset( $results, $results[0], $results[0]->numIps ) ) {
589 return 0;
590 }
591 return $results[0]->numIps;
592 }
593
594 /**
595 * Block the user, and log, do whatever is necessary.
596 *
597 * @param string $rule
598 * @param bool $bypass
599 * @return void
600 */
601 private function block_user( $rule, $bypass = false ) {
602 if ( ! $this->is_authenticated( $bypass ) ) {
603 $this->display_error_page( '55' . intval( $rule ) );
604 }
605 }
606
607 /**
608 * Log the user action.
609 *
610 * @param string $rule
611 * @return void
612 */
613 private function log_user( $rule ) {
614 $this->log_hacker( $rule, '', 'LOG' );
615 }
616
617 /**
618 * Log the user action and redirect.
619 *
620 * @param integer $rule_id
621 * @param string $redirect
622 * @return void
623 */
624 private function redirect_user( $rule_id, $redirect ) {
625 $this->log_hacker( $rule_id, '', 'REDIRECT' );
626
627 // Don't redirect an invalid URL.
628 if ( ! $redirect || stripos( $redirect, 'http' ) === false ) {
629 return;
630 }
631
632 ob_start();
633 header( 'Location: ' . $redirect );
634 ob_end_flush();
635 exit;
636 }
637
638 /**
639 * Determine if the user is authenticated and in the list of whitelisted roles.
640 *
641 * @param bool $bypass
642 * @return bool
643 */
644 public function is_authenticated( $bypass = false ) {
645 if ( $bypass || ! is_user_logged_in() ) {
646 return false;
647 }
648
649 // Special scenario for super admins on a multisite environment.
650 $roles = $this->get_option( 'patchstack_basic_firewall_roles', array( 'administrator', 'editor', 'author' ) );
651 if ( in_array( 'administrator', $roles ) && is_multisite() && is_super_admin() ) {
652 return true;
653 }
654
655 // User is logged in, determine the role.
656 $user = wp_get_current_user();
657 if ( ! isset( $user->roles ) || count( (array) $user->roles ) == 0 ) {
658 return false;
659 }
660
661 // Is the user in the whitelist roles list?
662 $role_count = array_intersect( $user->roles, $roles );
663 return count( $role_count ) != 0;
664 }
665
666 /**
667 * Log the blocked request.
668 *
669 * @param integer $fid firewall
670 * @param array $query_vars
671 * @param string $block_type
672 * @param array $block_params
673 * @return void
674 */
675 private function log_hacker( $fid = 1, $post_data = '', $block_type = 'BLOCK' ) {
676 global $wpdb;
677 if ( ! $wpdb || $fid == 22 ) {
678 return;
679 }
680
681 // Insert into the logs.
682 $wpdb->insert(
683 $wpdb->prefix . 'patchstack_firewall_log',
684 array(
685 'ip' => $this->get_ip(),
686 'request_uri' => isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '',
687 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '',
688 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : '',
689 'fid' => $fid,
690 'flag' => '',
691 'post_data' => $post_data != '' ? json_encode( $post_data ) : $this->get_post_data(),
692 'block_type' => $block_type,
693 )
694 );
695 }
696
697 /**
698 * Get POST data.
699 *
700 * @return string|NULL
701 */
702 private function get_post_data() {
703 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || $_SERVER['REQUEST_METHOD'] != 'POST' ) {
704 return null;
705 }
706
707 return json_encode( $_POST );
708 }
709
710 /**
711 * Display error page.
712 *
713 * @param integer $fid
714 * @return void
715 */
716 public function display_error_page( $fid = 1 ) {
717 if ( $fid != 22 ) {
718 $this->log_hacker( $fid );
719 }
720
721 header( 'Cache-Control: no-store' );
722 header( 'Pragma: no-cache' );
723 http_response_code( 403 );
724 require_once dirname( __FILE__ ) . '/views/access-denied.php';
725 exit;
726 }
727 }
728