PluginProbe
Patchstack – WordPress & Plugins Security / 2.3.3
Patchstack – WordPress & Plugins Security v2.3.3
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 / lib / patchstack / src / Extensions / WordPress / Extension.php

Extension.php in Patchstack – WordPress & Plugins Security 2.3.3, at lib/patchstack/src/Extensions/WordPress/Extension.php

503 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Patchstack\Extensions\WordPress;
4
5 use Patchstack\Extensions\ExtensionInterface;
6
7 class Extension implements ExtensionInterface
8 {
9 /**
10 * WordPress specific options that we need to remember.
11 *
12 * @var array
13 */
14 public $options = [
15 'patchstack_basic_firewall_roles' => ['administrator', 'editor', 'author'],
16 'patchstack_whitelist' => ''
17 ];
18
19 /**
20 * The request parameter values exploded into pairs.
21 *
22 * @var array
23 */
24 private $requestParams = [
25 'method' => 'method',
26 'rulesFile' => 'rules->file',
27 'rulesRawPost' => 'rules->raw->post',
28 'rulesUri' => 'rules->uri',
29 'rulesHeadersAll' => 'rules->headers->all',
30 'rulesHeadersKeys' => 'rules->headers->keys',
31 'rulesHeadersValues' => 'rules->headers->values',
32 'rulesHeadersCombinations' => 'rules->headers->combinations',
33 'rulesBodyAll' => 'rules->body->all',
34 'rulesBodyKeys' => 'rules->body->keys',
35 'rulesBodyValues' => 'rules->body->values',
36 'rulesBodyCombinations' => 'rules->body->combinations',
37 'rulesParamsAll' => 'rules->params->all',
38 'rulesParamsKeys' => 'rules->params->keys',
39 'rulesParamsValues' => 'rules->params->values',
40 'rulesParamsCombinations' => 'rules->params->combinations'
41 ];
42
43 /**
44 * The core of the Patchstack plugin.
45 *
46 * @var P_Core
47 */
48 private $core;
49
50 /**
51 * Creates a new extension instance.
52 *
53 * @var array $options
54 */
55 public function __construct($options, $core)
56 {
57 $this->options = array_merge($this->options, $options);
58 $this->core = $core;
59 }
60
61 /**
62 * Log the HTTP request.
63 *
64 * @param int $ruleId
65 * @param array $request
66 * @param string $logType
67 * @return void
68 */
69 public function logRequest($ruleId, $request, $logType = 'BLOCK')
70 {
71 global $wpdb;
72 if (!$wpdb) {
73 return;
74 }
75
76 // Transform raw payload.
77 if (is_array($request) && array_key_exists('raw', $request)) {
78 $request['raw'] = isset($request['raw']) && is_array($request['raw']) ? $request['raw'][0] : $request['raw'];
79
80 // Remove raw payload if not present.
81 if ((is_array($request['raw']) && count($request['raw'])) == 0 || empty($request['raw'])) {
82 unset($request['raw']);
83 }
84 }
85
86 // Remove files payload if not present.
87 if (isset($request['files']) && is_array($request['files']) && count($request['files']) == 0) {
88 unset($request['files']);
89 }
90
91 // Remove post payload if not present.
92 if (isset($request['post']) && is_array($request['post']) && count($request['post']) == 0) {
93 unset($request['post']);
94 }
95
96 // Insert into the logs.
97 $wpdb->insert(
98 $wpdb->prefix . 'patchstack_firewall_log',
99 [
100 'ip' => $this->getIpAddress(),
101 'request_uri' => isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '',
102 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
103 'method' => isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '',
104 'fid' => '55' . $ruleId,
105 'flag' => '',
106 'post_data' => json_encode($request),
107 'block_type' => $logType
108 ]
109 );
110
111 // Increment all time hits counter.
112 if ($logType == 'BLOCK') {
113 $hits = (int) get_option('patchstack_hits_all_time', 0);
114 update_option('patchstack_hits_all_time', $hits + 1);
115
116 $counters = get_option('patchstack_hits_last_30', []);
117 $counters = $this->merge_counters([date('Y-m-d') => 1], $counters);
118 update_option('patchstack_hits_last_30', $counters);
119 }
120 }
121
122 /**
123 * Determine if the current visitor can bypass the firewall.
124 * If $isMuCall is true, we MUST avoid any function calls that checks the current authorization of the user,
125 * this includes current_user_can. Otherwise, a fatal error is thrown.
126 *
127 * @param bool $isMuCall
128 * @return bool
129 */
130 public function canBypass($isMuCall)
131 {
132 if ($isMuCall || !is_user_logged_in()) {
133 return false;
134 }
135
136 // Get the whitelisted roles.
137 $roles = $this->options['patchstack_basic_firewall_roles'];
138 if (!is_array($roles)) {
139 return false;
140 }
141
142 // Special scenario for super admins on a multisite environment.
143 if (in_array('administrator', $roles) && is_multisite() && is_super_admin()) {
144 return true;
145 }
146
147 // Get the roles of the user.
148 $user = wp_get_current_user();
149 if (!isset($user->roles) || count((array) $user->roles) == 0) {
150 return false;
151 }
152
153 // Is the user in the whitelist roles list?
154 $role_count = array_intersect($user->roles, $roles);
155 return count($role_count) != 0;
156 }
157
158 /**
159 * Determine if the visitor is blocked from the website.
160 *
161 * @param int $minutes
162 * @param int $blockTime
163 * @param int $attempts
164 * @return bool
165 */
166 public function isBlocked($minutes, $blockTime, $attempts)
167 {
168 // Calculate block time.
169 if (empty($minutes) || empty($blockTime)) {
170 $time = 30 + 60;
171 } else {
172 $time = $minutes + $blockTime;
173 }
174
175 // Determine if the user should be blocked.
176 global $wpdb;
177 $results = $wpdb->get_results(
178 $wpdb->prepare(
179 "SELECT COUNT(*) as blockedCount
180 FROM " . $wpdb->prefix . "patchstack_firewall_log
181 WHERE block_type = 'BLOCK'
182 AND apply_ban = 1
183 AND ip = '%s'
184 AND log_date >= ('" . current_time('mysql') . "' - INTERVAL %d MINUTE)",
185 [$this->getIpAddress(), $time]
186 ),
187 OBJECT
188 );
189
190 if (!isset($results, $results[0], $results[0]->blockedCount)) {
191 return false;
192 }
193
194 return $results[0]->blockedCount > $attempts;
195 }
196
197 /**
198 * The response to return when a request has been blocked.
199 *
200 * @param int $fid
201 * @return void
202 */
203 public function forceExit($fid)
204 {
205 status_header(403);
206 send_nosniff_header();
207 nocache_headers();
208
209 // Supported by a number of popular caching plugins.
210 if (!defined( 'DONOTCACHEPAGE')) {
211 define('DONOTCACHEPAGE', true);
212 }
213
214 // Because WP Fastest Cache just has to be special...
215 if (function_exists('wpfc_exclude_current_page')) {
216 @wpfc_exclude_current_page();
217 }
218
219 include_once dirname(__FILE__) . '/../../../../../includes/views/access-denied.php';
220
221 exit;
222 }
223
224 /**
225 * Get the IP address of the request.
226 *
227 * @return string
228 */
229 public function getIpAddress()
230 {
231 return $this->core->get_ip();
232 }
233
234 /**
235 * Get the hostname of the environment.
236 * This is only used for open redirect vulnerabilities.
237 *
238 * @return string
239 */
240 public function getHostName()
241 {
242 return parse_url(home_url(), PHP_URL_HOST);
243 }
244
245 /**
246 * Check the custom whitelist rules defined in the backend of WordPress
247 * and attempt to match it with the request.
248 *
249 * @return boolean
250 */
251 private function isWhitelistedCustom()
252 {
253 $whitelist = str_replace( '<?php exit; ?>', '', $this->options['patchstack_whitelist'] );
254 if (empty($whitelist)) {
255 return false;
256 }
257
258 // Loop through all lines.
259 $lines = explode("\n", $whitelist);
260 if (count($lines) === 0) {
261 return false;
262 }
263
264 // Grab the IP address.
265 $ip = $this->getIpAddress();
266
267 // Loop through the whitelist entries.
268 foreach ($lines as $line) {
269 $t = explode(':', $line);
270
271 if (count($t) == 2) {
272 $val = strtolower(trim($t[1]));
273 switch (strtolower($t[0])) {
274 case 'ip': // IP address match.
275 if ($ip == $val) {
276 return true;
277 }
278 break;
279 case 'payload': // Payload match.
280 if (count($_POST) > 0 && strpos(strtolower(print_r($_POST, true)), $val) !== false) {
281 return true;
282 }
283
284 if (count($_GET) > 0 && strpos(strtolower(print_r($_GET, true)), $val) !== false) {
285 return true;
286 }
287 break;
288 case 'url': // URL match.
289 if (strpos(strtolower($_SERVER['REQUEST_URI']), $val) !== false) {
290 return true;
291 }
292 break;
293 }
294 }
295 }
296
297 return false;
298 }
299
300 /**
301 * Determine if the request is whitelisted.
302 *
303 * @param array $whitelistRules
304 * @param array $request
305 * @return boolean
306 */
307 public function isWhitelisted($whitelistRules, $request)
308 {
309 // First check if the user has custom whitelist rules configured.
310 if ($this->isWhitelistedCustom()) {
311 return true;
312 }
313
314 // Determine if there are any whitelist rules to process.
315 if (!is_array($whitelistRules) || count($whitelistRules) == 0) {
316 return false;
317 }
318
319 // Grab visitor's IP address and request data.
320 $clientIp = $this->getIpAddress();
321 $requests = $request;
322
323 foreach ($whitelistRules as $whitelist) {
324 $whitelistRule = json_decode($whitelist['rule']);
325
326 // If an IP address match is given, determine if it matches.
327 $ip = isset($whitelistRule->rules, $whitelistRule->rules->ip_address) ? $whitelistRule->rules->ip_address : null;
328 if (!is_null($ip)) {
329 if (strpos($ip, '*') !== false) {
330 $isWhitelistedIp = $this->check_wildcard_rule($clientIp, $ip);
331 } elseif (strpos($ip, '-') !== false) {
332 $isWhitelistedIp = $this->check_range_rule($clientIp, $ip);
333 } elseif (strpos($ip, '/') !== false) {
334 $isWhitelistedIp = $this->check_subnet_mask_rule($clientIp, $ip);
335 } elseif ($clientIp == $ip) {
336 $isWhitelistedIp = true;
337 } else {
338 $isWhitelistedIp = false;
339 }
340 } else {
341 $isWhitelistedIp = true;
342 }
343
344 foreach ($requests as $key => $request) {
345 // Treat the raw POST data string as the body contents of all values combined.
346 if ($key == 'rulesRawPost') {
347 $key = 'rulesBodyAll';
348 }
349
350 if (isset($this->requestParams[$key]) && ($whitelistRule->method == $requests['method'] || $whitelistRule->method == 'ALL')) {
351 $exp = explode('->', $this->requestParams[$key]);
352
353 // Determine if a rule exists for this request.
354 $rule = $whitelistRule;
355 foreach ($exp as $var) {
356 if (!isset($rule->$var)) {
357 $rule = null;
358 continue;
359 }
360 $rule = $rule->$var;
361 }
362
363 if (!is_null($rule) && substr($key, 0, 4) == 'rule' && $this->isLegacyRuleMatch($rule, $request) && $isWhitelistedIp) {
364 return true;
365 }
366 }
367 }
368 }
369
370 return false;
371 }
372
373 /**
374 * Determine if the request matches the given firewall or whitelist rule.
375 *
376 * @param string $rule
377 * @param string|array $request
378 * @return bool
379 */
380 private function isLegacyRuleMatch($rule, $request)
381 {
382 $is_matched = false;
383 if (is_array($request)) {
384 foreach ($request as $value) {
385 $is_matched = $this->isLegacyRuleMatch($rule, $value);
386 if ($is_matched) {
387 return $is_matched;
388 }
389 }
390 } else {
391 return preg_match($rule, urldecode($request));
392 }
393
394 return $is_matched;
395 }
396
397 /**
398 * Determine if the current request is a file upload request.
399 *
400 * @return boolean
401 */
402 public function isFileUploadRequest()
403 {
404 return isset($_FILES) && count($_FILES) > 0;
405 }
406
407 /**
408 * CIDR notation IP block check.
409 *
410 * @param string $ip The IP address of the user.
411 * @param string $range The range to check.
412 * @return boolean Whether or not the IP is in the range.
413 */
414 public function check_subnet_mask_rule( $ip, $range )
415 {
416 list($range, $netmask) = explode( '/', $range, 2 );
417 $range_decimal = ip2long( $range );
418 $ip_decimal = ip2long( $ip );
419 $wildcard_decimal = pow( 2, ( 32 - $netmask ) ) - 1;
420 $netmask_decimal = ~ $wildcard_decimal;
421 return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) );
422 }
423
424 /**
425 * Wildcard IP block check.
426 *
427 * @param string $ip The IP address of the user.
428 * @param string $rule The wildcard range to check against.
429 * @return boolean Whether or not the IP is in the wilcard range.
430 */
431 public function check_wildcard_rule( $ip, $rule )
432 {
433 $match = explode( '*', $rule );
434 $match = $match[0];
435 return ( substr( $ip, 0, strlen( $match ) ) == $match );
436 }
437
438 /**
439 * IP range block check.
440 *
441 * @param string|array $ip The IP address of the user.
442 * @param string $rule The range to check against.
443 * @return boolean Whether or not the IP is in the range.
444 */
445 public function check_range_rule( $ip, $rule )
446 {
447 // Check if client has multiple IPs
448 if ( is_array( $ip ) ) {
449 $ip = $ip[0];
450 }
451
452 $first_ip = explode( '-', $rule );
453 $second_ip = explode( '-', $rule );
454
455 $start_ip = ip2long( $first_ip[0] );
456 $end_ip = ip2long( $second_ip[1] );
457 $request_ip = ip2long( $ip );
458
459 return ( $request_ip >= $start_ip && $request_ip <= $end_ip );
460 }
461
462 /**
463 * Given an array of the current counters, merge it with the past counters.
464 *
465 * @param array $newCounters
466 * @param array $oldCounters
467 * @return array
468 */
469 public function merge_counters($newCounters, $oldCounters)
470 {
471 // The new counters to return.
472 $countersNow = [];
473 $oldCounters = is_array($oldCounters) ? $oldCounters : [];
474
475 // Set the range of dates we need.
476 $start = new \DateTime();
477 $start->modify('-30 days');
478 $end = new \DateTime();
479 $end->modify('+1 day');
480 $interval = new \DateInterval('P1D');
481 $range = new \DatePeriod($start, $interval, $end);
482
483 // Set the range from -6 days to +1 day from now.
484 foreach ($range as $date) {
485 $formattedDate = $date->format('Y-m-d');
486 if (isset($oldCounters[$formattedDate])) {
487 $countersNow[$formattedDate] = $oldCounters[$formattedDate];
488 } else {
489 $countersNow[$formattedDate] = 0;
490 }
491 }
492
493 // Update the counters with the ones passed from the firewall logger.
494 foreach ($newCounters as $date => $hits) {
495 if (isset($countersNow[$date])) {
496 $countersNow[$date] += $hits;
497 }
498 }
499
500 return $countersNow;
501 }
502 }
503