PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-waf/src/class-waf-runtime.php +169 -110 12.3.2 → 16.3 View file →
@@ -18,13 +18,16 @@
18 18 *
19 19 * @var string JETPACK_WAF_MODE
20 20 */
21 21
22 +// Type aliases for this file.
23 +<<<'PHAN'
24 +@phan-type Target = array{ only?: string[], except?: string[], count?: boolean }
25 +@phan-type TargetBag = array<string, Target>
26 +PHAN;
27 +
22 28 /**
23 29 * Waf_Runtime class
24 - *
25 - * @template Target as array{ only?: string[], except?: string[], count?: boolean }
26 - * @template TargetBag as array<string, Target>
27 30 */
28 31 class Waf_Runtime {
29 32 /**
30 33 * If used, normalize_array_targets() will just return the number of matching values, instead of the values themselves.
@@ -35,8 +38,16 @@
35 38 */
36 39 const NORMALIZE_ARRAY_MATCH_VALUES = 2;
37 40
38 41 /**
42 + * The version of this runtime class. Used by rule files to ensure compatibility.
43 + *
44 + * @since 0.21.0
45 + *
46 + * @var int
47 + */
48 + public $version = 1;
49 + /**
39 50 * Last rule.
40 51 *
41 52 * @var string
42 53 */
@@ -57,9 +68,9 @@
57 68 * Matched var names.
58 69 *
59 70 * @var array
60 71 */
61 - public $matched_var_names = array();
72 + public $matched_vars_names = array();
62 73 /**
63 74 * Matched var name.
64 75 *
65 76 * @var string
@@ -64,8 +75,14 @@
64 75 *
65 76 * @var string
66 77 */
67 78 public $matched_var_name = '';
79 + /**
80 + * Body Processor.
81 + *
82 + * @var string 'URLENCODED' | 'JSON' | ''
83 + */
84 + private $body_processor = '';
68 85
69 86 /**
70 87 * State.
71 88 *
@@ -123,9 +140,9 @@
123 140 * Constructor method.
124 141 *
125 142 * @param Waf_Transforms $transforms Transforms.
126 143 * @param Waf_Operators $operators Operators.
127 - * @param Waf_Request? $request Information about the request.
144 + * @param ?Waf_Request $request Information about the request.
128 145 */
129 146 public function __construct( $transforms, $operators, $request = null ) {
130 147 $this->transforms = $transforms;
131 148 $this->operators = $operators;
@@ -185,9 +202,9 @@
185 202 unset( $targets[ $name ] );
186 203 } else {
187 204 // otherwise just mark single props to ignore.
188 205 $targets[ $name ]['except'] = array_merge(
189 - isset( $targets[ $name ]['except'] ) ? $targets[ $name ]['except'] : array(),
206 + $targets[ $name ]['except'] ?? array(),
190 207 $props
191 208 );
192 209 }
193 210 }
@@ -206,13 +223,9 @@
206 223 * @param bool $capture Capture.
207 224 * @return bool
208 225 */
209 226 public function match_targets( $transforms, $targets, $match_operator, $match_value, $match_not, $capture = false ) {
210 - $this->matched_vars = array();
211 - $this->matched_var_names = array();
212 - $this->matched_var = '';
213 - $this->matched_var_name = '';
214 - $match_found = false;
227 + $match_found = false;
215 228
216 229 // get values.
217 230 $values = $this->normalize_targets( $targets );
218 231
@@ -221,9 +234,9 @@
221 234 foreach ( $values as &$v ) {
222 235 $v['value'] = $this->transforms->$t( $v['value'] );
223 236 }
224 237 }
225 -
238 + unset( $v );
226 239 // pass each target value to the operator to find any that match.
227 240 $matched = array();
228 241 $captures = array();
229 242 foreach ( $values as $v ) {
@@ -233,14 +246,14 @@
233 246 // If either:
234 247 // - rule is negated ("not" flag set) and the target was not matched
235 248 // - rule not negated and the target was matched
236 249 // then this is considered a match.
237 - $match_found = true;
238 - $this->matched_var_names[] = $v['source'];
239 - $this->matched_vars[] = $v['value'];
240 - $this->matched_var_name = end( $this->matched_var_names );
241 - $this->matched_var = end( $this->matched_vars );
242 - $matched[] = array( $v, $match );
250 + $match_found = true;
251 + $this->matched_vars_names[] = $v['name'];
252 + $this->matched_vars[] = $v['value'];
253 + $this->matched_var_name = end( $this->matched_vars_names );
254 + $this->matched_var = end( $this->matched_vars );
255 + $matched[] = array( $v, $match );
243 256 // Set any captured matches into state if the rule has the "capture" flag.
244 257 if ( $capture ) {
245 258 $captures = is_array( $match ) ? $match : array( $match );
246 259 foreach ( array_slice( $captures, 0, 10 ) as $i => $c ) {
@@ -253,106 +266,85 @@
253 266 return $match_found;
254 267 }
255 268
256 269 /**
257 - * Block.
270 + * Generate a secure hash for an IP address.
258 271 *
259 - * @param string $action Action.
260 - * @param string $rule_id Rule id.
261 - * @param string $reason Block reason.
262 - * @param int $status_code Http status code.
272 + * @param string $ip IP address.
273 + * @return string Hashed IP.
263 274 */
264 - public function block( $action, $rule_id, $reason, $status_code = 403 ) {
265 - if ( ! $reason ) {
266 - $reason = "rule $rule_id";
267 - } else {
268 - $reason = $this->sanitize_output( $reason );
269 - }
275 + private function get_ip_hash( string $ip ): string {
276 + $hash_key = wp_salt( 'auth' );
277 + return hash_hmac( 'sha256', $ip, $hash_key );
278 + }
270 279
271 - $this->write_blocklog( $rule_id, $reason );
272 - error_log( "Jetpack WAF Blocked Request\t$action\t$rule_id\t$status_code\t$reason" );
273 - header( "X-JetpackWAF-Blocked: $status_code - rule $rule_id" );
274 - if ( defined( 'JETPACK_WAF_MODE' ) && 'normal' === JETPACK_WAF_MODE ) {
275 - $protocol = isset( $_SERVER['SERVER_PROTOCOL'] ) ? wp_unslash( $_SERVER['SERVER_PROTOCOL'] ) : 'HTTP';
276 - header( $protocol . ' 403 Forbidden', true, $status_code );
277 - die( "rule $rule_id - reason $reason" );
278 - }
280 + /**
281 + * Check if the IP is allowed for recovery.
282 + *
283 + * @param string $ip IP address.
284 + * @return bool
285 + */
286 + public function is_ip_allowed_for_recovery( string $ip ): bool {
287 + $allow_hash = get_transient( 'jetpack_waf_recovery_' . $ip );
288 + return $allow_hash && hash_equals( $allow_hash, $this->get_ip_hash( $ip ) );
279 289 }
280 290
281 291 /**
282 - * Write block logs. We won't write to the file if it exceeds 100 mb.
292 + * Process a recovery attempt.
283 293 *
284 - * @param string $rule_id Rule id.
285 - * @param string $reason Block reason.
294 + * @param string $real_ip The real IP address of the request.
286 295 */
287 - public function write_blocklog( $rule_id, $reason ) {
288 - $log_data = array();
289 - $log_data['rule_id'] = $rule_id;
290 - $log_data['reason'] = $reason;
291 - $log_data['timestamp'] = gmdate( 'Y-m-d H:i:s' );
296 + private function allow_login_or_prompt_recovery( $real_ip ) {
297 + $blocked_login_page = Waf_Blocked_Login_Page::instance( $real_ip );
292 298
293 - if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) {
294 - $file_path = JETPACK_WAF_DIR . '/waf-blocklog';
295 - $file_exists = file_exists( $file_path );
296 -
297 - if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) {
298 - $fp = fopen( $file_path, 'a+' );
299 -
300 - if ( $fp ) {
301 - try {
302 - fwrite( $fp, json_encode( $log_data ) . "\n" );
303 - } finally {
304 - fclose( $fp );
305 - }
306 - }
307 - }
299 + if ( $blocked_login_page->is_blocked_user_valid() ) {
300 + // Allow the IP to bypass the block for 15 minutes.
301 + set_transient( 'jetpack_waf_recovery_' . $real_ip, $this->get_ip_hash( $real_ip ), 15 * 60 );
302 + return;
308 303 }
309 304
310 - $this->write_blocklog_row( $log_data );
305 + $blocked_login_page->render_and_die();
311 306 }
312 307
313 308 /**
314 - * Write block logs to database.
309 + * Block.
315 310 *
316 - * @param array $log_data Log data.
311 + * @param string $action Action.
312 + * @param string $rule_id Rule id.
313 + * @param string $reason Block reason.
314 + * @param int $status_code Http status code.
317 315 */
318 - private function write_blocklog_row( $log_data ) {
319 - $conn = $this->connect_to_wordpress_db();
316 + public function block( $action, $rule_id, $reason, $status_code = 403 ) {
317 + // The recovery flow needs transients, `wp_salt()` and `$pagenow`, so it cannot run in
318 + // standalone mode, where the WAF executes from `auto_prepend_file` before WordPress.
319 + if ( 'ip block list' === $reason && defined( 'ABSPATH' ) ) {
320 + $real_ip = $this->request->get_real_user_ip_address();
320 321
321 - if ( ! $conn ) {
322 - return;
323 - }
322 + if ( $this->is_ip_allowed_for_recovery( $real_ip ) ) {
323 + return;
324 + }
324 325
325 - global $table_prefix;
326 -
327 - $statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" );
328 - if ( false !== $statement ) {
329 - $statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] );
330 - $statement->execute();
331 -
332 - if ( $conn->insert_id > 100 ) {
333 - $conn->query( "DELETE FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id LIMIT 1" );
326 + global $pagenow;
327 + if ( isset( $pagenow ) && 'wp-login.php' === $pagenow ) {
328 + $this->allow_login_or_prompt_recovery( $real_ip );
329 + return;
334 330 }
335 331 }
336 - }
337 332
338 - /**
339 - * Connect to WordPress database.
340 - */
341 - private function connect_to_wordpress_db() {
342 - if ( ! file_exists( JETPACK_WAF_WPCONFIG ) ) {
343 - return;
333 + if ( ! $reason ) {
334 + $reason = "rule $rule_id";
335 + } else {
336 + $reason = $this->sanitize_output( $reason );
344 337 }
345 338
346 - require_once JETPACK_WAF_WPCONFIG;
347 - $conn = new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli
348 -
349 - if ( $conn->connect_error ) {
350 - error_log( 'Could not connect to the database:' . $conn->connect_error );
351 - return null;
339 + Waf_Blocklog_Manager::write_blocklog( $rule_id, $reason );
340 + error_log( "Jetpack WAF Blocked Request\t$action\t$rule_id\t$status_code\t$reason" );
341 + header( "X-JetpackWAF-Blocked: $status_code - rule $rule_id" );
342 + if ( defined( 'JETPACK_WAF_MODE' ) && 'normal' === JETPACK_WAF_MODE ) {
343 + $protocol = isset( $_SERVER['SERVER_PROTOCOL'] ) ? wp_unslash( $_SERVER['SERVER_PROTOCOL'] ) : 'HTTP';
344 + header( $protocol . ' 403 Forbidden', true, $status_code );
345 + die( "rule $rule_id - reason $reason" );
352 346 }
353 -
354 - return $conn;
355 347 }
356 348
357 349 /**
358 350 * Redirect.
@@ -358,13 +350,14 @@
358 350 * Redirect.
359 351 *
360 352 * @param string $rule_id Rule id.
361 353 * @param string $url Url.
354 + * @return never
362 355 */
363 356 public function redirect( $rule_id, $url ) {
364 357 error_log( "Jetpack WAF Redirected Request.\tRule:$rule_id\t$url" );
365 358 header( "Location: $url" );
366 - exit;
359 + exit( 0 );
367 360 }
368 361
369 362 /**
370 363 * Flag rule for removal.
@@ -405,11 +398,9 @@
405 398 *
406 399 * @param string $key Key.
407 400 */
408 401 public function get_var( $key ) {
409 - return isset( $this->state[ $key ] )
410 - ? $this->state[ $key ]
411 - : '';
402 + return $this->state[ $key ] ?? '';
412 403 }
413 404
414 405 /**
415 406 * Set variable value.
@@ -494,9 +485,9 @@
494 485 $this->request->get_protocol()
495 486 );
496 487 break;
497 488 case 'request_basename':
498 - $value = basename( $this->request->get_filename() );
489 + $value = $this->request->get_basename();
499 490 break;
500 491 case 'request_body':
501 492 $value = $this->request->get_body();
502 493 break;
@@ -509,9 +500,9 @@
509 500 case 'args_get_names':
510 501 $value = $this->args_names( $this->meta( 'args_get' ) );
511 502 break;
512 503 case 'args_post':
513 - $value = $this->request->get_post_vars();
504 + $value = $this->request->get_post_vars( $this->get_body_processor() );
514 505 break;
515 506 case 'args_post_names':
516 507 $value = $this->args_names( $this->meta( 'args_post' ) );
517 508 break;
@@ -535,8 +526,20 @@
535 526 break;
536 527 case 'files_names':
537 528 $value = $this->args_names( $this->meta( 'files' ) );
538 529 break;
530 + case 'matched_vars':
531 + $value = array_combine( $this->matched_vars_names, $this->matched_vars );
532 + break;
533 + case 'matched_var':
534 + $value = array( $this->matched_var_name => $this->matched_var );
535 + break;
536 + case 'matched_vars_names':
537 + $value = $this->matched_vars_names;
538 + break;
539 + case 'matched_var_name':
540 + $value = array( $this->matched_var_name );
541 + break;
539 542 }
540 543 $this->metadata[ $key ] = $value;
541 544 }
542 545
@@ -560,8 +563,30 @@
560 563 return $output;
561 564 }
562 565
563 566 /**
567 + * Get the body processor.
568 + *
569 + * @return string
570 + */
571 + private function get_body_processor() {
572 + return $this->body_processor;
573 + }
574 +
575 + /**
576 + * Set the body processor.
577 + *
578 + * @param string $processor Processor to set. Either 'URLENCODED' or 'JSON'.
579 + *
580 + * @return void
581 + */
582 + public function set_body_processor( $processor ) {
583 + if ( $processor === 'URLENCODED' || $processor === 'JSON' ) {
584 + $this->body_processor = $processor;
585 + }
586 + }
587 +
588 + /**
564 589 * Change a string to all lowercase and replace spaces and underscores with dashes.
565 590 *
566 591 * @param string $name Name.
567 592 * @return string
@@ -588,16 +613,16 @@
588 613 * source: For targets that are associative arrays (like ARGS), this will be the target name AND the key in that target (i.e. "args:z" for ARGS:z)
589 614 * value: The value that was found in the associated target.
590 615 *
591 616 * @param TargetBag $targets An assoc. array with keys that are target name(s) and values are options for how to process that target (include/exclude rules, whether to return values or counts).
592 - * @return array{ name: string, source: string, value: mixed }
617 + * @return array{name: string, source: string, value: mixed}[]
593 618 */
594 619 public function normalize_targets( $targets ) {
595 620 $return = array();
596 621 foreach ( $targets as $k => $v ) {
597 622 $count_only = isset( $v['count'] ) ? self::NORMALIZE_ARRAY_COUNT : 0;
598 - $only = isset( $v['only'] ) ? $v['only'] : array();
599 - $except = isset( $v['except'] ) ? $v['except'] : array();
623 + $only = $v['only'] ?? array();
624 + $except = $v['except'] ?? array();
600 625 $_k = strtolower( $k );
601 626 switch ( $_k ) {
602 627 case 'request_headers':
603 628 $this->normalize_array_target(
@@ -649,11 +674,27 @@
649 674 $this->meta( substr( $_k, 0, -6 ) )
650 675 );
651 676 $this->normalize_array_target( $data, $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
652 677 continue 2;
678 + case 'matched_var':
679 + $this->normalize_array_target( $this->meta( $k ), $only, $except, $k, $return, $count_only );
680 + continue 2;
681 +
682 + case 'matched_var_name':
683 + $this->normalize_array_target( $this->meta( $k ), $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
684 + continue 2;
685 +
686 + case 'matched_vars':
687 + $this->normalize_array_target( $this->meta( $k ), $only, $except, $k, $return, $count_only );
688 + continue 2;
689 +
690 + case 'matched_vars_names':
691 + $this->normalize_array_target( $this->meta( $k ), $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
692 + continue 2;
693 +
653 694 default:
654 695 var_dump( 'Unknown target', $k, $v );
655 - exit;
696 + exit( 0 );
656 697 }
657 698 $return[] = array(
658 699 'name' => $k,
659 700 'value' => $v,
@@ -664,8 +705,26 @@
664 705 return $return;
665 706 }
666 707
667 708 /**
709 + * Reset matched vars after processing a rule.
710 + *
711 + * @return void
712 + */
713 + public function reset_matched_vars() {
714 + $this->matched_vars = array();
715 + $this->matched_vars_names = array();
716 + $this->matched_var = '';
717 + $this->matched_var_name = '';
718 + unset(
719 + $this->metadata['matched_var'],
720 + $this->metadata['matched_vars'],
721 + $this->metadata['matched_vars_names'],
722 + $this->metadata['matched_var_name']
723 + );
724 + }
725 +
726 + /**
668 727 * Verifies if the IP from the current request is in an array.
669 728 *
670 729 * @param array $array Array of IP addresses to verify the request IP against.
671 730 * @return bool
@@ -674,10 +733,10 @@
674 733 $real_ip = $this->request->get_real_user_ip_address();
675 734 $array_length = count( $array );
676 735
677 736 for ( $i = 0; $i < $array_length; $i++ ) {
678 - // Check if the IP matches a provided range.
679 - $range = explode( '-', $array[ $i ] );
737 + // Check if the IP matches a provided range or CIDR notation.
738 + $range = strpos( $array[ $i ], '/' ) !== false ? array( $array[ $i ], null ) : explode( '-', $array[ $i ] );
680 739 if ( count( $range ) === 2 ) {
681 740 if ( IP_Utils::ip_address_is_in_range( $real_ip, $range[0], $range[1] ) ) {
682 741 return true;
683 742 }
@@ -695,14 +754,14 @@
695 754
696 755 /**
697 756 * Extract values from an associative array, potentially applying filters and/or counting results.
698 757 *
699 - * @param array{ 0: string, 1: scalar }|scalar[] $source The source assoc. array of values (i.e. $_GET, $_SERVER, etc.).
700 - * @param string[] $only Only include the values for these keys in the output.
701 - * @param string[] $excl Never include the values for these keys in the output.
702 - * @param string $name The name of this target (see https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v3.x)#Variables).
703 - * @param array $results Array to add output values to, will be modified by this method.
704 - * @param int $flags Any of the NORMALIZE_ARRAY_* constants defined at the top of the class.
758 + * @param array{0: string, 1: scalar}|scalar[] $source The source assoc. array of values (i.e. $_GET, $_SERVER, etc.).
759 + * @param string[] $only Only include the values for these keys in the output.
760 + * @param string[] $excl Never include the values for these keys in the output.
761 + * @param string $name The name of this target (see https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v3.x)#Variables).
762 + * @param array $results Array to add output values to, will be modified by this method.
763 + * @param int $flags Any of the NORMALIZE_ARRAY_* constants defined at the top of the class.
705 764 */
706 765 private function normalize_array_target( $source, $only, $excl, $name, &$results, $flags = 0 ) {
707 766 $output = array();
708 767 $has_only = isset( $only[0] );