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 +163 -129 13.3.3 → 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
@@ -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,130 +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 - }
270 -
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 - }
275 + private function get_ip_hash( string $ip ): string {
276 + $hash_key = wp_salt( 'auth' );
277 + return hash_hmac( 'sha256', $ip, $hash_key );
279 278 }
280 279
281 280 /**
282 - * Get the headers for logging purposes.
281 + * Check if the IP is allowed for recovery.
282 + *
283 + * @param string $ip IP address.
284 + * @return bool
283 285 */
284 - public function get_request_headers() {
285 - $all_headers = getallheaders();
286 - $exclude_headers = array( 'Authorization', 'Cookie', 'Proxy-Authorization', 'Set-Cookie' );
287 -
288 - foreach ( $exclude_headers as $header ) {
289 - unset( $all_headers[ $header ] );
290 - }
291 -
292 - return $all_headers;
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 ) );
293 289 }
294 290
295 291 /**
296 - * Write block logs. We won't write to the file if it exceeds 100 mb.
292 + * Process a recovery attempt.
297 293 *
298 - * @param string $rule_id Rule id.
299 - * @param string $reason Block reason.
294 + * @param string $real_ip The real IP address of the request.
300 295 */
301 - public function write_blocklog( $rule_id, $reason ) {
302 - $log_data = array();
303 - $log_data['rule_id'] = $rule_id;
304 - $log_data['reason'] = $reason;
305 - $log_data['timestamp'] = gmdate( 'Y-m-d H:i:s' );
306 - $log_data['request_uri'] = isset( $_SERVER['REQUEST_URI'] ) ? \stripslashes( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
307 - $log_data['user_agent'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \stripslashes( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
308 - $log_data['referer'] = isset( $_SERVER['HTTP_REFERER'] ) ? \stripslashes( $_SERVER['HTTP_REFERER'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
309 - $log_data['content_type'] = isset( $_SERVER['CONTENT_TYPE'] ) ? \stripslashes( $_SERVER['CONTENT_TYPE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
310 - $log_data['get_params'] = json_encode( $_GET );
296 + private function allow_login_or_prompt_recovery( $real_ip ) {
297 + $blocked_login_page = Waf_Blocked_Login_Page::instance( $real_ip );
311 298
312 - if ( defined( 'JETPACK_WAF_SHARE_DEBUG_DATA' ) && JETPACK_WAF_SHARE_DEBUG_DATA ) {
313 - $log_data['post_params'] = json_encode( $_POST );
314 - $log_data['headers'] = $this->get_request_headers();
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;
315 303 }
316 304
317 - if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) {
318 - $file_path = JETPACK_WAF_DIR . '/waf-blocklog';
319 - $file_exists = file_exists( $file_path );
320 -
321 - if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) {
322 - $fp = fopen( $file_path, 'a+' );
323 -
324 - if ( $fp ) {
325 - try {
326 - fwrite( $fp, json_encode( $log_data ) . "\n" );
327 - } finally {
328 - fclose( $fp );
329 - }
330 - }
331 - }
332 - }
333 -
334 - $this->write_blocklog_row( $log_data );
305 + $blocked_login_page->render_and_die();
335 306 }
336 307
337 308 /**
338 - * Write block logs to database.
309 + * Block.
339 310 *
340 - * @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.
341 315 */
342 - private function write_blocklog_row( $log_data ) {
343 - $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();
344 321
345 - if ( ! $conn ) {
346 - return;
347 - }
322 + if ( $this->is_ip_allowed_for_recovery( $real_ip ) ) {
323 + return;
324 + }
348 325
349 - global $table_prefix;
350 -
351 - $statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" );
352 - if ( false !== $statement ) {
353 - $statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] );
354 - $statement->execute();
355 -
356 - if ( $conn->insert_id > 100 ) {
357 - $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;
358 330 }
359 331 }
360 - }
361 332
362 - /**
363 - * Connect to WordPress database.
364 - */
365 - private function connect_to_wordpress_db() {
366 - if ( ! file_exists( JETPACK_WAF_WPCONFIG ) ) {
367 - return;
333 + if ( ! $reason ) {
334 + $reason = "rule $rule_id";
335 + } else {
336 + $reason = $this->sanitize_output( $reason );
368 337 }
369 338
370 - require_once JETPACK_WAF_WPCONFIG;
371 - $conn = new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli
372 -
373 - if ( $conn->connect_error ) {
374 - error_log( 'Could not connect to the database:' . $conn->connect_error );
375 - 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" );
376 346 }
377 -
378 - return $conn;
379 347 }
380 348
381 349 /**
382 350 * Redirect.
@@ -387,9 +355,9 @@
387 355 */
388 356 public function redirect( $rule_id, $url ) {
389 357 error_log( "Jetpack WAF Redirected Request.\tRule:$rule_id\t$url" );
390 358 header( "Location: $url" );
391 - exit;
359 + exit( 0 );
392 360 }
393 361
394 362 /**
395 363 * Flag rule for removal.
@@ -430,11 +398,9 @@
430 398 *
431 399 * @param string $key Key.
432 400 */
433 401 public function get_var( $key ) {
434 - return isset( $this->state[ $key ] )
435 - ? $this->state[ $key ]
436 - : '';
402 + return $this->state[ $key ] ?? '';
437 403 }
438 404
439 405 /**
440 406 * Set variable value.
@@ -534,9 +500,9 @@
534 500 case 'args_get_names':
535 501 $value = $this->args_names( $this->meta( 'args_get' ) );
536 502 break;
537 503 case 'args_post':
538 - $value = $this->request->get_post_vars();
504 + $value = $this->request->get_post_vars( $this->get_body_processor() );
539 505 break;
540 506 case 'args_post_names':
541 507 $value = $this->args_names( $this->meta( 'args_post' ) );
542 508 break;
@@ -560,8 +526,20 @@
560 526 break;
561 527 case 'files_names':
562 528 $value = $this->args_names( $this->meta( 'files' ) );
563 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;
564 542 }
565 543 $this->metadata[ $key ] = $value;
566 544 }
567 545
@@ -585,8 +563,30 @@
585 563 return $output;
586 564 }
587 565
588 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 + /**
589 589 * Change a string to all lowercase and replace spaces and underscores with dashes.
590 590 *
591 591 * @param string $name Name.
592 592 * @return string
@@ -613,16 +613,16 @@
613 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)
614 614 * value: The value that was found in the associated target.
615 615 *
616 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).
617 - * @return array{ name: string, source: string, value: mixed }
617 + * @return array{name: string, source: string, value: mixed}[]
618 618 */
619 619 public function normalize_targets( $targets ) {
620 620 $return = array();
621 621 foreach ( $targets as $k => $v ) {
622 622 $count_only = isset( $v['count'] ) ? self::NORMALIZE_ARRAY_COUNT : 0;
623 - $only = isset( $v['only'] ) ? $v['only'] : array();
624 - $except = isset( $v['except'] ) ? $v['except'] : array();
623 + $only = $v['only'] ?? array();
624 + $except = $v['except'] ?? array();
625 625 $_k = strtolower( $k );
626 626 switch ( $_k ) {
627 627 case 'request_headers':
628 628 $this->normalize_array_target(
@@ -674,11 +674,27 @@
674 674 $this->meta( substr( $_k, 0, -6 ) )
675 675 );
676 676 $this->normalize_array_target( $data, $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
677 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 +
678 694 default:
679 695 var_dump( 'Unknown target', $k, $v );
680 - exit;
696 + exit( 0 );
681 697 }
682 698 $return[] = array(
683 699 'name' => $k,
684 700 'value' => $v,
@@ -689,8 +705,26 @@
689 705 return $return;
690 706 }
691 707
692 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 + /**
693 727 * Verifies if the IP from the current request is in an array.
694 728 *
695 729 * @param array $array Array of IP addresses to verify the request IP against.
696 730 * @return bool
@@ -699,10 +733,10 @@
699 733 $real_ip = $this->request->get_real_user_ip_address();
700 734 $array_length = count( $array );
701 735
702 736 for ( $i = 0; $i < $array_length; $i++ ) {
703 - // Check if the IP matches a provided range.
704 - $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 ] );
705 739 if ( count( $range ) === 2 ) {
706 740 if ( IP_Utils::ip_address_is_in_range( $real_ip, $range[0], $range[1] ) ) {
707 741 return true;
708 742 }
@@ -720,14 +754,14 @@
720 754
721 755 /**
722 756 * Extract values from an associative array, potentially applying filters and/or counting results.
723 757 *
724 - * @param array{ 0: string, 1: scalar }|scalar[] $source The source assoc. array of values (i.e. $_GET, $_SERVER, etc.).
725 - * @param string[] $only Only include the values for these keys in the output.
726 - * @param string[] $excl Never include the values for these keys in the output.
727 - * @param string $name The name of this target (see https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v3.x)#Variables).
728 - * @param array $results Array to add output values to, will be modified by this method.
729 - * @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.
730 764 */
731 765 private function normalize_array_target( $source, $only, $excl, $name, &$results, $flags = 0 ) {
732 766 $output = array();
733 767 $has_only = isset( $only[0] );