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 +150 -119 13.4.5 → 16.3 View file →
@@ -19,9 +19,9 @@
19 19 * @var string JETPACK_WAF_MODE
20 20 */
21 21
22 22 // Type aliases for this file.
23 -<<<PHAN
23 +<<<'PHAN'
24 24 @phan-type Target = array{ only?: string[], except?: string[], count?: boolean }
25 25 @phan-type TargetBag = array<string, Target>
26 26 PHAN;
27 27
@@ -38,8 +38,16 @@
38 38 */
39 39 const NORMALIZE_ARRAY_MATCH_VALUES = 2;
40 40
41 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 + /**
42 50 * Last rule.
43 51 *
44 52 * @var string
45 53 */
@@ -60,9 +68,9 @@
60 68 * Matched var names.
61 69 *
62 70 * @var array
63 71 */
64 - public $matched_var_names = array();
72 + public $matched_vars_names = array();
65 73 /**
66 74 * Matched var name.
67 75 *
68 76 * @var string
@@ -67,8 +75,14 @@
67 75 *
68 76 * @var string
69 77 */
70 78 public $matched_var_name = '';
79 + /**
80 + * Body Processor.
81 + *
82 + * @var string 'URLENCODED' | 'JSON' | ''
83 + */
84 + private $body_processor = '';
71 85
72 86 /**
73 87 * State.
74 88 *
@@ -188,9 +202,9 @@
188 202 unset( $targets[ $name ] );
189 203 } else {
190 204 // otherwise just mark single props to ignore.
191 205 $targets[ $name ]['except'] = array_merge(
192 - isset( $targets[ $name ]['except'] ) ? $targets[ $name ]['except'] : array(),
206 + $targets[ $name ]['except'] ?? array(),
193 207 $props
194 208 );
195 209 }
196 210 }
@@ -209,13 +223,9 @@
209 223 * @param bool $capture Capture.
210 224 * @return bool
211 225 */
212 226 public function match_targets( $transforms, $targets, $match_operator, $match_value, $match_not, $capture = false ) {
213 - $this->matched_vars = array();
214 - $this->matched_var_names = array();
215 - $this->matched_var = '';
216 - $this->matched_var_name = '';
217 - $match_found = false;
227 + $match_found = false;
218 228
219 229 // get values.
220 230 $values = $this->normalize_targets( $targets );
221 231
@@ -236,14 +246,14 @@
236 246 // If either:
237 247 // - rule is negated ("not" flag set) and the target was not matched
238 248 // - rule not negated and the target was matched
239 249 // then this is considered a match.
240 - $match_found = true;
241 - $this->matched_var_names[] = $v['source'];
242 - $this->matched_vars[] = $v['value'];
243 - $this->matched_var_name = end( $this->matched_var_names );
244 - $this->matched_var = end( $this->matched_vars );
245 - $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 );
246 256 // Set any captured matches into state if the rule has the "capture" flag.
247 257 if ( $capture ) {
248 258 $captures = is_array( $match ) ? $match : array( $match );
249 259 foreach ( array_slice( $captures, 0, 10 ) as $i => $c ) {
@@ -256,130 +266,85 @@
256 266 return $match_found;
257 267 }
258 268
259 269 /**
260 - * Block.
270 + * Generate a secure hash for an IP address.
261 271 *
262 - * @param string $action Action.
263 - * @param string $rule_id Rule id.
264 - * @param string $reason Block reason.
265 - * @param int $status_code Http status code.
272 + * @param string $ip IP address.
273 + * @return string Hashed IP.
266 274 */
267 - public function block( $action, $rule_id, $reason, $status_code = 403 ) {
268 - if ( ! $reason ) {
269 - $reason = "rule $rule_id";
270 - } else {
271 - $reason = $this->sanitize_output( $reason );
272 - }
273 -
274 - $this->write_blocklog( $rule_id, $reason );
275 - error_log( "Jetpack WAF Blocked Request\t$action\t$rule_id\t$status_code\t$reason" );
276 - header( "X-JetpackWAF-Blocked: $status_code - rule $rule_id" );
277 - if ( defined( 'JETPACK_WAF_MODE' ) && 'normal' === JETPACK_WAF_MODE ) {
278 - $protocol = isset( $_SERVER['SERVER_PROTOCOL'] ) ? wp_unslash( $_SERVER['SERVER_PROTOCOL'] ) : 'HTTP';
279 - header( $protocol . ' 403 Forbidden', true, $status_code );
280 - die( "rule $rule_id - reason $reason" );
281 - }
275 + private function get_ip_hash( string $ip ): string {
276 + $hash_key = wp_salt( 'auth' );
277 + return hash_hmac( 'sha256', $ip, $hash_key );
282 278 }
283 279
284 280 /**
285 - * 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
286 285 */
287 - public function get_request_headers() {
288 - $all_headers = getallheaders();
289 - $exclude_headers = array( 'Authorization', 'Cookie', 'Proxy-Authorization', 'Set-Cookie' );
290 -
291 - foreach ( $exclude_headers as $header ) {
292 - unset( $all_headers[ $header ] );
293 - }
294 -
295 - 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 ) );
296 289 }
297 290
298 291 /**
299 - * Write block logs. We won't write to the file if it exceeds 100 mb.
292 + * Process a recovery attempt.
300 293 *
301 - * @param string $rule_id Rule id.
302 - * @param string $reason Block reason.
294 + * @param string $real_ip The real IP address of the request.
303 295 */
304 - public function write_blocklog( $rule_id, $reason ) {
305 - $log_data = array();
306 - $log_data['rule_id'] = $rule_id;
307 - $log_data['reason'] = $reason;
308 - $log_data['timestamp'] = gmdate( 'Y-m-d H:i:s' );
309 - $log_data['request_uri'] = isset( $_SERVER['REQUEST_URI'] ) ? \stripslashes( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
310 - $log_data['user_agent'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \stripslashes( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
311 - $log_data['referer'] = isset( $_SERVER['HTTP_REFERER'] ) ? \stripslashes( $_SERVER['HTTP_REFERER'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
312 - $log_data['content_type'] = isset( $_SERVER['CONTENT_TYPE'] ) ? \stripslashes( $_SERVER['CONTENT_TYPE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
313 - $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 );
314 298
315 - if ( defined( 'JETPACK_WAF_SHARE_DEBUG_DATA' ) && JETPACK_WAF_SHARE_DEBUG_DATA ) {
316 - $log_data['post_params'] = json_encode( $_POST );
317 - $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;
318 303 }
319 304
320 - if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) {
321 - $file_path = JETPACK_WAF_DIR . '/waf-blocklog';
322 - $file_exists = file_exists( $file_path );
323 -
324 - if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) {
325 - $fp = fopen( $file_path, 'a+' );
326 -
327 - if ( $fp ) {
328 - try {
329 - fwrite( $fp, json_encode( $log_data ) . "\n" );
330 - } finally {
331 - fclose( $fp );
332 - }
333 - }
334 - }
335 - }
336 -
337 - $this->write_blocklog_row( $log_data );
305 + $blocked_login_page->render_and_die();
338 306 }
339 307
340 308 /**
341 - * Write block logs to database.
309 + * Block.
342 310 *
343 - * @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.
344 315 */
345 - private function write_blocklog_row( $log_data ) {
346 - $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();
347 321
348 - if ( ! $conn ) {
349 - return;
350 - }
322 + if ( $this->is_ip_allowed_for_recovery( $real_ip ) ) {
323 + return;
324 + }
351 325
352 - global $table_prefix;
353 -
354 - $statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" );
355 - if ( false !== $statement ) {
356 - $statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] );
357 - $statement->execute();
358 -
359 - if ( $conn->insert_id > 100 ) {
360 - $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;
361 330 }
362 331 }
363 - }
364 332
365 - /**
366 - * Connect to WordPress database.
367 - */
368 - private function connect_to_wordpress_db() {
369 - if ( ! file_exists( JETPACK_WAF_WPCONFIG ) ) {
370 - return;
333 + if ( ! $reason ) {
334 + $reason = "rule $rule_id";
335 + } else {
336 + $reason = $this->sanitize_output( $reason );
371 337 }
372 338
373 - require_once JETPACK_WAF_WPCONFIG;
374 - $conn = new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli
375 -
376 - if ( $conn->connect_error ) {
377 - error_log( 'Could not connect to the database:' . $conn->connect_error );
378 - 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" );
379 346 }
380 -
381 - return $conn;
382 347 }
383 348
384 349 /**
385 350 * Redirect.
@@ -390,9 +355,9 @@
390 355 */
391 356 public function redirect( $rule_id, $url ) {
392 357 error_log( "Jetpack WAF Redirected Request.\tRule:$rule_id\t$url" );
393 358 header( "Location: $url" );
394 - exit;
359 + exit( 0 );
395 360 }
396 361
397 362 /**
398 363 * Flag rule for removal.
@@ -433,11 +398,9 @@
433 398 *
434 399 * @param string $key Key.
435 400 */
436 401 public function get_var( $key ) {
437 - return isset( $this->state[ $key ] )
438 - ? $this->state[ $key ]
439 - : '';
402 + return $this->state[ $key ] ?? '';
440 403 }
441 404
442 405 /**
443 406 * Set variable value.
@@ -537,9 +500,9 @@
537 500 case 'args_get_names':
538 501 $value = $this->args_names( $this->meta( 'args_get' ) );
539 502 break;
540 503 case 'args_post':
541 - $value = $this->request->get_post_vars();
504 + $value = $this->request->get_post_vars( $this->get_body_processor() );
542 505 break;
543 506 case 'args_post_names':
544 507 $value = $this->args_names( $this->meta( 'args_post' ) );
545 508 break;
@@ -563,8 +526,20 @@
563 526 break;
564 527 case 'files_names':
565 528 $value = $this->args_names( $this->meta( 'files' ) );
566 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;
567 542 }
568 543 $this->metadata[ $key ] = $value;
569 544 }
570 545
@@ -588,8 +563,30 @@
588 563 return $output;
589 564 }
590 565
591 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 + /**
592 589 * Change a string to all lowercase and replace spaces and underscores with dashes.
593 590 *
594 591 * @param string $name Name.
595 592 * @return string
@@ -622,10 +619,10 @@
622 619 public function normalize_targets( $targets ) {
623 620 $return = array();
624 621 foreach ( $targets as $k => $v ) {
625 622 $count_only = isset( $v['count'] ) ? self::NORMALIZE_ARRAY_COUNT : 0;
626 - $only = isset( $v['only'] ) ? $v['only'] : array();
627 - $except = isset( $v['except'] ) ? $v['except'] : array();
623 + $only = $v['only'] ?? array();
624 + $except = $v['except'] ?? array();
628 625 $_k = strtolower( $k );
629 626 switch ( $_k ) {
630 627 case 'request_headers':
631 628 $this->normalize_array_target(
@@ -677,11 +674,27 @@
677 674 $this->meta( substr( $_k, 0, -6 ) )
678 675 );
679 676 $this->normalize_array_target( $data, $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
680 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 +
681 694 default:
682 695 var_dump( 'Unknown target', $k, $v );
683 - exit;
696 + exit( 0 );
684 697 }
685 698 $return[] = array(
686 699 'name' => $k,
687 700 'value' => $v,
@@ -692,8 +705,26 @@
692 705 return $return;
693 706 }
694 707
695 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 + /**
696 727 * Verifies if the IP from the current request is in an array.
697 728 *
698 729 * @param array $array Array of IP addresses to verify the request IP against.
699 730 * @return bool
@@ -702,10 +733,10 @@
702 733 $real_ip = $this->request->get_real_user_ip_address();
703 734 $array_length = count( $array );
704 735
705 736 for ( $i = 0; $i < $array_length; $i++ ) {
706 - // Check if the IP matches a provided range.
707 - $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 ] );
708 739 if ( count( $range ) === 2 ) {
709 740 if ( IP_Utils::ip_address_is_in_range( $real_ip, $range[0], $range[1] ) ) {
710 741 return true;
711 742 }