diff options
Diffstat (limited to 'plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-runtime.php')
-rw-r--r-- | plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-runtime.php | 794 |
1 files changed, 794 insertions, 0 deletions
diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-runtime.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-runtime.php new file mode 100644 index 00000000..19206821 --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-runtime.php @@ -0,0 +1,794 @@ +<?php +/** + * Runtime for Jetpack Waf + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +require_once __DIR__ . '/functions.php'; + +// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- This class is all about sanitizing input. + +/** + * The environment variable that defined the WAF running mode. + * + * @var string JETPACK_WAF_MODE + */ + +/** + * Waf_Runtime class + */ +class Waf_Runtime { + + /** + * Last rule. + * + * @var string + */ + public $last_rule = ''; + /** + * Matched vars. + * + * @var array + */ + public $matched_vars = array(); + /** + * Matched var. + * + * @var string + */ + public $matched_var = ''; + /** + * Matched var names. + * + * @var array + */ + public $matched_var_names = array(); + /** + * Matched var name. + * + * @var string + */ + public $matched_var_name = ''; + + /** + * State. + * + * @var array + */ + private $state = array(); + /** + * Metadata. + * + * @var array + */ + private $metadata = array(); + + /** + * Transforms. + * + * @var Waf_Transforms[] + */ + private $transforms; + /** + * Operators. + * + * @var Waf_Operators[] + */ + private $operators; + + /** + * Rules to remove. + * + * @var array[] + */ + private $rules_to_remove = array( + 'id' => array(), + 'tag' => array(), + ); + + /** + * Targets to remove. + * + * @var array[] + */ + private $targets_to_remove = array( + 'id' => array(), + 'tag' => array(), + ); + + /** + * Constructor method. + * + * @param Waf_Transforms $transforms Transforms. + * @param Waf_Operators $operators Operators. + */ + public function __construct( $transforms, $operators ) { + $this->transforms = $transforms; + $this->operators = $operators; + } + + /** + * Rule removed method. + * + * @param string $id Ids. + * @param string[] $tags Tags. + */ + public function rule_removed( $id, $tags ) { + if ( isset( $this->rules_to_remove['id'][ $id ] ) ) { + return true; + } + foreach ( $tags as $tag ) { + if ( isset( $this->rules_to_remove['tag'][ $tag ] ) ) { + return true; + } + } + return false; + } + + /** + * Update Targets. + * + * @param array $targets Targets. + * @param string $rule_id Rule id. + * @param string[] $rule_tags Rule tags. + */ + public function update_targets( $targets, $rule_id, $rule_tags ) { + $updates = array(); + // look for target updates based on the rule's ID. + if ( isset( $this->targets_to_remove['id'][ $rule_id ] ) ) { + foreach ( $this->targets_to_remove['id'][ $rule_id ] as $name => $props ) { + $updates[] = array( $name, $props ); + } + } + // look for target updates based on the rule's tags. + foreach ( $rule_tags as $tag ) { + if ( isset( $this->targets_to_remove['tag'][ $tag ] ) ) { + foreach ( $this->targets_to_remove['tag'][ $tag ] as $name => $props ) { + $updates[] = array( $name, $props ); + } + } + } + // apply any found target updates. + + foreach ( $updates as list( $name, $props ) ) { + if ( isset( $targets[ $name ] ) ) { + // we only need to remove targets that exist. + if ( true === $props ) { + // if the entire target is being removed, remove it. + unset( $targets[ $name ] ); + } else { + // otherwise just mark single props to ignore. + $targets[ $name ]['except'] = array_merge( + isset( $targets[ $name ]['except'] ) ? $targets[ $name ]['except'] : array(), + $props + ); + } + } + } + return $targets; + } + + /** + * Return TRUE if at least one of the targets matches the rule. + * + * @param string[] $transforms One of the transform methods defined in the Jetpack Waf_Transforms class. + * @param mixed $targets Targets. + * @param string $match_operator Match operator. + * @param mixed $match_value Match value. + * @param bool $match_not Match not. + * @param bool $capture Capture. + * @return bool + */ + public function match_targets( $transforms, $targets, $match_operator, $match_value, $match_not, $capture = false ) { + $this->matched_vars = array(); + $this->matched_var_names = array(); + $this->matched_var = ''; + $this->matched_var_name = ''; + $match_found = false; + + // get values. + $values = $this->normalize_targets( $targets ); + + // apply transforms. + foreach ( $transforms as $t ) { + foreach ( $values as &$v ) { + $v['value'] = $this->transforms->$t( $v['value'] ); + } + } + + // pass each target value to the operator to find any that match. + $matched = array(); + $captures = array(); + foreach ( $values as $v ) { + $match = $this->operators->{$match_operator}( $v['value'], $match_value ); + $did_match = false !== $match; + if ( $match_not !== $did_match ) { + // If either: + // - rule is negated ("not" flag set) and the target was not matched + // - rule not negated and the target was matched + // then this is considered a match. + $match_found = true; + $this->matched_var_names[] = $v['source']; + $this->matched_vars[] = $v['value']; + $this->matched_var_name = end( $this->matched_var_names ); + $this->matched_var = end( $this->matched_vars ); + $matched[] = array( $v, $match ); + // Set any captured matches into state if the rule has the "capture" flag. + if ( $capture ) { + $captures = is_array( $match ) ? $match : array( $match ); + foreach ( array_slice( $captures, 0, 10 ) as $i => $c ) { + $this->set_var( "tx.$i", $c ); + } + } + } + } + + return $match_found; + } + + /** + * Block. + * + * @param string $action Action. + * @param string $rule_id Rule id. + * @param string $reason Block reason. + * @param int $status_code Http status code. + */ + public function block( $action, $rule_id, $reason, $status_code = 403 ) { + if ( ! $reason ) { + $reason = "rule $rule_id"; + } else { + $reason = $this->sanitize_output( $reason ); + } + + $this->write_blocklog( $rule_id, $reason ); + error_log( "Jetpack WAF Blocked Request\t$action\t$rule_id\t$status_code\t$reason" ); + header( "X-JetpackWAF-Blocked: $status_code - rule $rule_id" ); + if ( defined( 'JETPACK_WAF_MODE' ) && 'normal' === JETPACK_WAF_MODE ) { + $protocol = isset( $_SERVER['SERVER_PROTOCOL'] ) ? wp_unslash( $_SERVER['SERVER_PROTOCOL'] ) : 'HTTP'; + header( $protocol . ' 403 Forbidden', true, $status_code ); + die( "rule $rule_id - reason $reason" ); + } + } + + /** + * Write block logs. We won't write to the file if it exceeds 100 mb. + * + * @param string $rule_id Rule id. + * @param string $reason Block reason. + */ + public function write_blocklog( $rule_id, $reason ) { + $log_data = array(); + $log_data['rule_id'] = $rule_id; + $log_data['reason'] = $reason; + $log_data['timestamp'] = gmdate( 'Y-m-d H:i:s' ); + + if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) { + $file_path = JETPACK_WAF_DIR . '/waf-blocklog'; + $file_exists = file_exists( $file_path ); + + if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) { + $fp = fopen( $file_path, 'a+' ); + + if ( $fp ) { + try { + fwrite( $fp, json_encode( $log_data ) . "\n" ); + } finally { + fclose( $fp ); + } + } + } + } + + $this->write_blocklog_row( $log_data ); + } + + /** + * Write block logs to database. + * + * @param array $log_data Log data. + */ + private function write_blocklog_row( $log_data ) { + $conn = $this->connect_to_wordpress_db(); + + if ( ! $conn ) { + return; + } + + global $table_prefix; + + $statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" ); + if ( false !== $statement ) { + $statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] ); + $statement->execute(); + + if ( $conn->insert_id > 100 ) { + $conn->query( "DELETE FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id LIMIT 1" ); + } + } + } + + /** + * Connect to WordPress database. + */ + private function connect_to_wordpress_db() { + if ( ! file_exists( JETPACK_WAF_WPCONFIG ) ) { + return; + } + + require_once JETPACK_WAF_WPCONFIG; + $conn = new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli + + if ( $conn->connect_error ) { + error_log( 'Could not connect to the database:' . $conn->connect_error ); + return null; + } + + return $conn; + } + + /** + * Redirect. + * + * @param string $rule_id Rule id. + * @param string $url Url. + */ + public function redirect( $rule_id, $url ) { + error_log( "Jetpack WAF Redirected Request.\tRule:$rule_id\t$url" ); + header( "Location: $url" ); + exit; + } + + /** + * Flag rule for removal. + * + * @param string $prop Prop. + * @param string $value Value. + */ + public function flag_rule_for_removal( $prop, $value ) { + if ( 'id' === $prop ) { + $this->rules_to_remove['id'][ $value ] = true; + } else { + $this->rules_to_remove['tag'][ $value ] = true; + } + } + + /** + * Flag target for removal. + * + * @param string $id_or_tag Id or tag. + * @param string $id_or_tag_value Id or tag value. + * @param string $name Name. + * @param string $prop Prop. + */ + public function flag_target_for_removal( $id_or_tag, $id_or_tag_value, $name, $prop = null ) { + if ( null === $prop ) { + $this->targets_to_remove[ $id_or_tag ][ $id_or_tag_value ][ $name ] = true; + } else { + if ( + ! isset( $this->targets_to_remove[ $id_or_tag ][ $id_or_tag_value ][ $name ] ) + // if the entire target is already being removed then it would be redundant to remove a single property. + || true !== $this->targets_to_remove[ $id_or_tag ][ $id_or_tag_value ][ $name ] + ) { + $this->targets_to_remove[ $id_or_tag ][ $id_or_tag_value ][ $name ][] = $prop; + } + } + } + + /** + * Get variable value. + * + * @param string $key Key. + */ + public function get_var( $key ) { + return isset( $this->state[ $key ] ) + ? $this->state[ $key ] + : ''; + } + + /** + * Set variable value. + * + * @param string $key Key. + * @param string $value Value. + */ + public function set_var( $key, $value ) { + $this->state[ $key ] = $value; + } + + /** + * Increment variable. + * + * @param string $key Key. + * @param mixed $value Value. + */ + public function inc_var( $key, $value ) { + if ( ! isset( $this->state[ $key ] ) ) { + $this->state[ $key ] = 0; + } + $this->state[ $key ] += floatval( $value ); + } + + /** + * Decrement variable. + * + * @param string $key Key. + * @param mixed $value Value. + */ + public function dec_var( $key, $value ) { + if ( ! isset( $this->state[ $key ] ) ) { + $this->state[ $key ] = 0; + } + $this->state[ $key ] -= floatval( $value ); + } + + /** + * Unset variable. + * + * @param string $key Key. + */ + public function unset_var( $key ) { + unset( $this->state[ $key ] ); + } + + /** + * Meta. + * + * @param string $key Key. + * @param string $prop Prop. + */ + public function meta( $key, $prop = false ) { + if ( ! isset( $this->metadata[ $key ] ) ) { + $value = null; + switch ( $key ) { + case 'headers': + $value = array(); + foreach ( $_SERVER as $k => $v ) { + $k = strtolower( $k ); + if ( 'http_' === substr( $k, 0, 5 ) ) { + $value[ $this->normalize_header_name( substr( $k, 5 ) ) ] = $v; + } elseif ( 'content_type' === $k ) { + $value['content-type'] = $v; + } elseif ( 'content_length' === $k ) { + $value['content-length'] = $v; + } + } + $value['content-type'] = ( ! isset( $value['content-type'] ) || '' === $value['content-type'] ) + // default Content-Type per RFC 7231 section 3.1.5.5. + ? 'application/octet-stream' + : $value['content-type']; + $value['content-length'] = ( isset( $value['content-length'] ) && '' !== $value['content-length'] ) + ? $value['content-length'] + // if the content-length header is missing, default it to zero. + : '0'; + break; + case 'remote_addr': + $value = ''; + if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) { + $value = wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ); + } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { + $value = wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ); + } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) { + $value = wp_unslash( $_SERVER['REMOTE_ADDR'] ); + } + break; + case 'request_method': + $value = empty( $_SERVER['REQUEST_METHOD'] ) + ? 'GET' + : wp_unslash( $_SERVER['REQUEST_METHOD'] ); + break; + case 'request_protocol': + $value = empty( $_SERVER['SERVER_PROTOCOL'] ) + ? ( empty( $_SERVER['HTTPS'] ) ? 'HTTP' : 'HTTPS' ) + : wp_unslash( $_SERVER['SERVER_PROTOCOL'] ); + break; + case 'request_uri': + $value = isset( $_SERVER['REQUEST_URI'] ) + ? wp_unslash( $_SERVER['REQUEST_URI'] ) + : ''; + break; + case 'request_uri_raw': + $value = ( isset( $_SERVER['https'] ) ? 'https://' : 'http://' ) . ( isset( $_SERVER['SERVER_NAME'] ) ? wp_unslash( $_SERVER['SERVER_NAME'] ) : '' ) . $this->meta( 'request_uri' ); + break; + case 'request_filename': + $value = strtok( + isset( $_SERVER['REQUEST_URI'] ) + ? wp_unslash( $_SERVER['REQUEST_URI'] ) + : '', + '?' + ); + break; + case 'request_line': + $value = sprintf( + '%s %s %s', + $this->meta( 'request_method' ), + $this->meta( 'request_uri' ), + $this->meta( 'request_protocol' ) + ); + break; + case 'request_basename': + $value = basename( $this->meta( 'request_filename' ) ); + break; + case 'request_body': + $value = file_get_contents( 'php://input' ); + break; + case 'query_string': + $value = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; + } + $this->metadata[ $key ] = $value; + } + + return false === $prop + ? $this->metadata[ $key ] + : ( isset( $this->metadata[ $key ][ $prop ] ) ? $this->metadata[ $key ][ $prop ] : '' ); + } + + /** + * State values. + * + * @param string $prefix Prefix. + */ + private function state_values( $prefix ) { + $output = array(); + $len = strlen( $prefix ); + foreach ( $this->state as $k => $v ) { + if ( 0 === stripos( $k, $prefix ) ) { + $output[ substr( $k, $len ) ] = $v; + } + } + + return $output; + } + + /** + * Change a string to all lowercase and replace spaces and underscores with dashes. + * + * @param string $name Name. + * @return string + */ + public function normalize_header_name( $name ) { + return str_replace( array( ' ', '_' ), '-', strtolower( $name ) ); + } + + /** + * Normalize targets. + * + * @param array $targets Targets. + */ + public function normalize_targets( $targets ) { + $return = array(); + foreach ( $targets as $k => $v ) { + $count_only = isset( $v['count'] ); + $only = isset( $v['only'] ) ? $v['only'] : array(); + $except = isset( $v['except'] ) ? $v['except'] : array(); + $_k = strtolower( $k ); + switch ( $_k ) { + case 'request_headers': + $only = array_map( + function ( $t ) { + return '/' === $t[0] ? $t : $this->normalize_header_name( $t ); + }, + $only + ); + $except = array_map( + function ( $t ) { + return '/' === $t[0] ? $t : $this->normalize_header_name( $t ); + }, + $except + ); + $this->normalize_array_target( $this->meta( 'headers' ), $only, $except, $k, $return, $count_only ); + continue 2; + case 'request_headers_names': + $this->normalize_array_target( array_keys( $this->meta( 'headers' ) ), array(), array(), $k, $return, $count_only ); + continue 2; + case 'request_method': + case 'request_protocol': + case 'request_uri': + case 'request_uri_raw': + case 'request_filename': + case 'remote_addr': + case 'request_basename': + case 'request_body': + case 'query_string': + case 'request_line': + $v = $this->meta( $_k ); + break; + case 'tx': + case 'ip': + $this->normalize_array_target( $this->state_values( "$k." ), $only, $except, $k, $return, $count_only ); + continue 2; + case 'request_cookies': + $this->normalize_array_target( $_COOKIE, $only, $except, $k, $return, $count_only ); + continue 2; + case 'request_cookies_names': + $this->normalize_array_target( array_keys( $_COOKIE ), array(), array(), $k, $return, $count_only ); + continue 2; + case 'args': + $this->normalize_array_target( $_REQUEST, $only, $except, $k, $return, $count_only ); + continue 2; + case 'args_names': + $this->normalize_array_target( array_keys( $_REQUEST ), array(), array(), $k, $return, $count_only ); + continue 2; + case 'args_get': + $this->normalize_array_target( $_GET, $only, $except, $k, $return, $count_only ); + continue 2; + case 'args_get_names': + $this->normalize_array_target( array_keys( $_GET ), array(), array(), $k, $return, $count_only ); + continue 2; + case 'args_post': + $this->normalize_array_target( $_POST, $only, $except, $k, $return, $count_only ); + continue 2; + case 'args_post_names': + $this->normalize_array_target( array_keys( $_POST ), array(), array(), $k, $return, $count_only ); + continue 2; + case 'files': + $names = array_map( + function ( $f ) { + return $f['name']; + }, + $_FILES + ); + $this->normalize_array_target( $names, $only, $except, $k, $return, $count_only ); + continue 2; + case 'files_names': + $this->normalize_array_target( array_keys( $_FILES ), $only, $except, $k, $return, $count_only ); + continue 2; + default: + var_dump( 'Unknown target', $k, $v ); + exit; + } + $return[] = array( + 'name' => $k, + 'value' => $v, + 'source' => $k, + ); + } + + return $return; + } + + /** + * Verifies is ip from request is in an array. + * + * @param array $array Array to verify ip against. + */ + public function is_ip_in_array( $array ) { + $request = new Waf_Request(); + + $real_ip = $request->get_real_user_ip_address(); + + return in_array( $real_ip, $array, true ); + } + + /** + * Normalize array target. + * + * @param array $source Source. + * @param array $only Only. + * @param array $excl Excl. + * @param string $name Name. + * @param array $results Results. + * @param bool $count_only Count only. + */ + private function normalize_array_target( $source, $only, $excl, $name, &$results, $count_only ) { + $output = array(); + $has_only = isset( $only[0] ); + $has_excl = isset( $excl[0] ); + + if ( $has_only ) { + foreach ( $only as $prop ) { + if ( isset( $source[ $prop ] ) && $this->key_matches( $prop, $only ) ) { + $output[ $prop ] = $source[ $prop ]; + } + } + } else { + $output = $source; + } + + if ( $has_excl ) { + foreach ( array_keys( $output ) as $k ) { + if ( $this->key_matches( $k, $excl ) ) { + unset( $output[ $k ] ); + } + } + } + + if ( $count_only ) { + $results[] = array( + 'name' => $name, + 'value' => count( $output ), + 'source' => '&' . $name, + ); + } else { + foreach ( $output as $tk => $tv ) { + if ( is_array( $tv ) ) { + // flatten it so we get all the values considered + $flat_values = $this->array_flatten( $tv ); + foreach ( $flat_values as $fv ) { + $results[] = array( + // force names to strings + // we don't care about the nested keys here, just the overall variable name + 'name' => '' . $tk, + 'value' => $fv, + 'source' => "$name:$tk", + ); + } + } else { + $results[] = array( + // force names to strings + 'name' => '' . $tk, + 'value' => $tv, + 'source' => "$name:$tk", + ); + } + } + } + + return $results; + } + + /** + * Basic array flatten with array_merge; no-op on non-array targets. + * + * @param array $source Array to flatten. + * @return array The flattened array. + */ + private function array_flatten( $source ) { + if ( ! is_array( $source ) ) { + return $source; + } + + $return = array(); + + foreach ( $source as $v ) { + if ( is_array( $v ) ) { + $return = array_merge( $return, $this->array_flatten( $v ) ); + } else { + $return[] = $v; + } + } + + return $return; + } + + /** + * Key matches. + * + * @param string $input Input. + * @param array $patterns Patterns. + */ + private function key_matches( $input, $patterns ) { + foreach ( $patterns as $p ) { + if ( '/' === $p[0] ) { + if ( 1 === preg_match( $p, $input ) ) { + return true; + } + } else { + if ( 0 === strcasecmp( $p, $input ) ) { + return true; + } + } + } + + return false; + } + + /** + * Sanitize output generated from the request that was blocked. + * + * @param string $output Output to sanitize. + */ + public function sanitize_output( $output ) { + $url_decoded_output = rawurldecode( $output ); + $html_entities_output = htmlentities( $url_decoded_output, ENT_QUOTES, 'UTF-8' ); + // @phpcs:disable Squiz.Strings.DoubleQuoteUsage.NotRequired + $escapers = array( "\\", "/", "\"", "\n", "\r", "\t", "\x08", "\x0c" ); + $replacements = array( "\\\\", "\\/", "\\\"", "\\n", "\\r", "\\t", "\\f", "\\b" ); + // @phpcs:enable Squiz.Strings.DoubleQuoteUsage.NotRequired + + return( str_replace( $escapers, $replacements, $html_entities_output ) ); + } +} |