diff options
Diffstat (limited to 'plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src')
11 files changed, 2526 insertions, 0 deletions
diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-cli.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-cli.php new file mode 100644 index 00000000..5ce887dc --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-cli.php @@ -0,0 +1,165 @@ +<?php +/** + * CLI handler for Jetpack Waf. + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +use \WP_CLI; +use \WP_CLI_Command; + +/** + * Just a few sample commands to learn how WP-CLI works + */ +class CLI extends WP_CLI_Command { + /** + * View or set the current mode of the WAF. + * ## OPTIONS + * + * [<mode>] + * : The new mode to be set. + * --- + * options: + * - silent + * - normal + * --- + * + * @param array $args Arguments passed to CLI. + * @return void|null + * @throws WP_CLI\ExitException If there is an error switching the mode. + */ + public function mode( $args ) { + if ( count( $args ) > 1 ) { + + return WP_CLI::error( __( 'Only one mode may be specified.', 'jetpack-waf' ) ); + } + if ( count( $args ) === 1 ) { + if ( ! Waf_Runner::is_allowed_mode( $args[0] ) ) { + + return WP_CLI::error( + sprintf( + /* translators: %1$s is the mode that was actually found. Also note that the expected "silent" and "normal" are hard-coded strings and must therefore stay the same in any translation. */ + __( 'Invalid mode: %1$s. Expected "silent" or "normal".', 'jetpack-waf' ), + $args[0] + ) + ); + } + + update_option( Waf_Runner::MODE_OPTION_NAME, $args[0] ); + + try { + ( new Waf_Standalone_Bootstrap() )->generate(); + } catch ( \Exception $e ) { + WP_CLI::warning( + sprintf( + /* translators: %1$s is the unexpected error message. */ + __( 'Unable to generate waf bootstrap - standalone mode may not work properly: %1$s', 'jetpack-waf' ), + $e->getMessage() + ) + ); + } + + return WP_CLI::success( + sprintf( + /* translators: %1$s is the name of the mode that was just switched to. */ + __( 'Jetpack WAF mode switched to "%1$s".', 'jetpack-waf' ), + get_option( Waf_Runner::MODE_OPTION_NAME ) + ) + ); + } + WP_CLI::line( + sprintf( + /* translators: %1$s is the name of the mode that the waf is currently running in. */ + __( 'Jetpack WAF is running in "%1$s" mode.', 'jetpack-waf' ), + get_option( Waf_Runner::MODE_OPTION_NAME ) + ) + ); + } + + /** + * Setup the WAF to run. + * ## OPTIONS + * + * [<mode>] + * : The new mode to be set. + * --- + * options: + * - silent + * - normal + * --- + * + * @param array $args Arguments passed to CLI. + * @return void|null + * @throws WP_CLI\ExitException If there is an error switching the mode. + */ + public function setup( $args ) { + // Let is_allowed_mode know we are running from the CLI + define( 'WAF_CLI_MODE', $args[0] ); + + // Set the mode and generate the bootstrap + $this->mode( array( $args[0] ) ); + + try { + // Add relevant options and generate the rules.php file + Waf_Runner::activate(); + } catch ( \Exception $e ) { + + return WP_CLI::error( + sprintf( + /* translators: %1$s is the unexpected error message. */ + __( 'Jetpack WAF rules file failed to generate: %1$s', 'jetpack-waf' ), + $e->getMessage() + ) + ); + } + + return WP_CLI::success( __( 'Jetpack WAF has successfully been setup.', 'jetpack-waf' ) ); + } + + /** + * Delete the WAF options. + * + * @return void|null + * @throws WP_CLI\ExitException If deactivating has failures. + */ + public function teardown() { + try { + Waf_Runner::deactivate(); + } catch ( \Exception $e ) { + WP_CLI::error( __( 'Jetpack WAF failed to fully deactivate.', 'jetpack-waf' ) ); + } + + return WP_CLI::success( __( 'Jetpack WAF has been deactivated.', 'jetpack-waf' ) ); + } + + /** + * Generate the rules.php file with latest rules for the WAF. + * + * @return void|null + * @throws WP_CLI\ExitException If there is an error switching the mode. + */ + public function generate_rules() { + try { + Waf_Runner::generate_rules(); + } catch ( \Exception $e ) { + + return WP_CLI::error( + sprintf( + /* translators: %1$s is the unexpected error message. */ + __( 'Jetpack WAF rules file failed to generate: %1$s', 'jetpack-waf' ), + $e->getMessage() + ) + ); + } + + return WP_CLI::success( + sprintf( + /* translators: %1$s is the name of the mode that was just switched to. */ + __( 'Jetpack WAF rules successfully created to: "%1$s".', 'jetpack-waf' ), + Waf_Runner::RULES_FILE + ) + ); + } +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-constants.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-constants.php new file mode 100644 index 00000000..0cfa3446 --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-constants.php @@ -0,0 +1,27 @@ +<?php +/** + * Class use to define the constants used by the WAF + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +/** + * Defines our constants. + */ +class Waf_Constants { + /** + * Initializes the constants required for generating the bootstrap, if they have not been initialized yet. + * + * @return void + */ + public static function initialize_constants() { + if ( ! defined( 'JETPACK_WAF_DIR' ) ) { + define( 'JETPACK_WAF_DIR', trailingslashit( WP_CONTENT_DIR ) . 'jetpack-waf' ); + } + if ( ! defined( 'JETPACK_WAF_WPCONFIG' ) ) { + define( 'JETPACK_WAF_WPCONFIG', trailingslashit( WP_CONTENT_DIR ) . '../wp-config.php' ); + } + } +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-endpoints.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-endpoints.php new file mode 100644 index 00000000..2aff96a5 --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-endpoints.php @@ -0,0 +1,111 @@ +<?php +/** + * Class use to register REST API endpoints used by the WAF + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +use Automattic\Jetpack\Connection\REST_Connector; +use WP_REST_Server; + +/** + * Defines our endponts. + */ +class Waf_Endpoints { + /** + * Get Bootstrap File Path + * + * @return string The path to the Jetpack Firewall's bootstrap.php file. + */ + private static function get_bootstrap_file_path() { + $bootstrap = new Waf_Standalone_Bootstrap(); + return $bootstrap->get_bootstrap_file_path(); + } + + /** + * Has Rules Access + * + * @return bool True when the current site has access to latest firewall rules. + */ + private static function has_rules_access() { + // any site with Jetpack Scan can download new WAF rules + return \Jetpack_Plan::supports( 'scan' ); + } + + /** + * Register REST API endpoints. + */ + public static function register_endpoints() { + register_rest_route( + 'jetpack/v4', + '/waf', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::waf', + 'permission_callback' => __CLASS__ . '::waf_permissions_callback', + ) + ); + register_rest_route( + 'jetpack/v4', + '/waf/update-rules', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::update_rules', + 'permission_callback' => __CLASS__ . '::waf_permissions_callback', + ) + ); + } + + /** + * Update rules endpoint + */ + public static function update_rules() { + $success = true; + $message = 'Rules updated succesfully'; + + try { + Waf_Runner::generate_rules(); + } catch ( Exception $e ) { + $success = false; + $message = $e->getMessage(); + } + + return rest_ensure_response( + array( + 'success' => $success, + 'message' => $message, + ) + ); + } + + /** + * WAF Endpoint + */ + public static function waf() { + return rest_ensure_response( + array( + 'bootstrapPath' => self::get_bootstrap_file_path(), + 'hasRulesAccess' => self::has_rules_access(), + ) + ); + } + + /** + * WAF Endpoint Permissions Callback + * + * @return bool|WP_Error True if user can view the Jetpack admin page. + */ + public static function waf_permissions_callback() { + if ( current_user_can( 'jetpack_manage_modules' ) ) { + return true; + } + + return new WP_Error( + 'invalid_user_permission_manage_modules', + REST_Connector::get_user_permissions_error_msg(), + array( 'status' => rest_authorization_required_code() ) + ); + } +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-initializer.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-initializer.php new file mode 100644 index 00000000..662a2ca2 --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-initializer.php @@ -0,0 +1,39 @@ +<?php +/** + * Class use to initialize the WAF module. + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +/** + * Initializes the module + */ +class Waf_Initializer { + /** + * Initializes the configurations needed for the waf module. + * + * @return void + */ + public static function init() { + add_action( 'jetpack_activate_module_waf', __CLASS__ . '::on_activation' ); + add_action( 'jetpack_deactivate_module_waf', __CLASS__ . '::on_deactivation' ); + } + + /** + * On module activation set up waf mode + */ + public static function on_activation() { + update_option( Waf_Runner::MODE_OPTION_NAME, 'normal' ); + Waf_Runner::activate(); + ( new Waf_Standalone_Bootstrap() )->generate(); + } + + /** + * On module deactivation, unset waf mode + */ + public static function on_deactivation() { + Waf_Runner::deactivate(); + } +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-operators.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-operators.php new file mode 100644 index 00000000..503fe797 --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-operators.php @@ -0,0 +1,286 @@ +<?php +/** + * Rule compiler for Jetpack Waf. + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +/** + * Waf_Operators class + */ +class Waf_Operators { + /** + * Returns true if the test string is found at the beginning of the input. + * + * @param string $input Input. + * @param string $test Test. + * @return string|false + */ + public function begins_with( $input, $test ) { + if ( '' === $input && '' === $test ) { + return ''; + } + + return substr( $input, 0, strlen( $test ) ) === $test + ? $test + : false; + } + + /** + * Returns true if the test string is found anywhere in the input. + * + * @param string $input Input. + * @param string $test Test. + * @return string|false + */ + public function contains( $input, $test ) { + if ( empty( $input ) || empty( $test ) ) { + return false; + } + + return strpos( $input, $test ) !== false + ? $test + : false; + } + + /** + * Returns true if the test string with word boundaries is found anywhere in the input. + * + * @param string $input Input. + * @param string $test Test. + * @return string|false + */ + public function contains_word( $input, $test ) { + return ( $input === $test || 1 === preg_match( '/\b' . preg_quote( $test, '/' ) . '\b/Ds', $input ) ) + ? $test + : false; + } + + /** + * Returns true if the test string is found at the end of the input. + * + * @param string $input Input. + * @param string $test Test. + * @return string|false + */ + public function ends_with( $input, $test ) { + return ( '' === $test || substr( $input, -1 * strlen( $test ) ) === $test ) + ? $test + : false; + } + + /** + * Returns true if the input value is equal to the test value. + * If either value cannot be converted to an int it will be treated as 0. + * + * @param mixed $input Input. + * @param mixed $test Test. + * @return int|false + */ + public function eq( $input, $test ) { + return intval( $input ) === intval( $test ) + ? $input + : false; + } + + /** + * Returns true if the input value is greater than or equal to the test value. + * If either value cannot be converted to an int it will be treated as 0. + * + * @param mixed $input Input. + * @param mixed $test Test. + * @return int|false + */ + public function ge( $input, $test ) { + return intval( $input ) >= intval( $test ) + ? $input + : false; + } + + /** + * Returns true if the input value is greater than the test value. + * If either value cannot be converted to an int it will be treated as 0. + * + * @param mixed $input Input. + * @param mixed $test Test. + * @return int|false + */ + public function gt( $input, $test ) { + return intval( $input ) > intval( $test ) + ? $input + : false; + } + + /** + * Returns true if the input value is less than or equal to the test value. + * If either value cannot be converted to an int it will be treated as 0. + * + * @param mixed $input Input. + * @param mixed $test Test. + * @return int|false + */ + public function le( $input, $test ) { + return intval( $input ) <= intval( $test ) + ? $input + : false; + } + + /** + * Returns true if the input value is less than the test value. + * If either value cannot be converted to an int it will be treated as 0. + * + * @param mixed $input Input. + * @param mixed $test Test. + * @return int|false + */ + public function lt( $input, $test ) { + return intval( $input ) < intval( $test ) + ? $input + : false; + } + + /** + * Returns false. + * + * @return false + */ + public function no_match() { + return false; + } + + /** + * Uses a multi-string matching algorithm to search through $input for a number of given $words. + * + * @param string $input Input. + * @param string[] $words \AhoCorasick\MultiStringMatcher $matcher. + * @return string[]|false Returns the words that were found in $input, or FALSE if no words were found. + */ + public function pm( $input, $words ) { + $results = $this->get_multi_string_matcher( $words )->searchIn( $input ); + + return isset( $results[0] ) + ? array_map( + function ( $r ) { + return $r[1]; }, + $results + ) + : false; + } + + /** + * The last-used pattern-matching algorithm. + * + * @var array + */ + private $last_multi_string_matcher = array( null, null ); + + /** + * Creates a matcher that uses the Aho-Corasick algorithm to efficiently find a number of words in an input string. + * Caches the last-used matcher so that the same word list doesn't have to be compiled multiple times. + * + * @param string[] $words Words. + * @return \AhoCorasick\MultiStringMatcher + */ + private function get_multi_string_matcher( $words ) { + // only create a new matcher entity if we don't have one already for this word list. + if ( $this->last_multi_string_matcher[0] !== $words ) { + $this->last_multi_string_matcher = array( $words, new \AhoCorasick\MultiStringMatcher( $words ) ); + } + + return $this->last_multi_string_matcher[1]; + } + + /** + * Performs a regular expression match on the input subject using the given pattern. + * Returns false if the pattern does not match, or the substring(s) of the input + * that were matched by the pattern. + * + * @param string $subject Subject. + * @param string $pattern Pattern. + * @return string[]|false + */ + public function rx( $subject, $pattern ) { + $matched = preg_match( $pattern, $subject, $matches ); + return 1 === $matched + ? $matches + : false; + } + + /** + * Returns true if the given input string matches the test string. + * + * @param string $input Input. + * @param string $test Test. + * @return string|false + */ + public function streq( $input, $test ) { + return $input === $test + ? $test + : false; + } + + /** + * Returns true. + * + * @param string $input Input. + * @return bool + */ + public function unconditional_match( $input ) { + return $input; + } + + /** + * Checks to see if the input string only contains characters within the given byte range + * + * @param string $input Input. + * @param array $valid_range Valid range. + * @return string + */ + public function validate_byte_range( $input, $valid_range ) { + if ( '' === $input ) { + // an empty string is considered "valid". + return false; + } + $i = 0; + while ( isset( $input[ $i ] ) ) { + $n = ord( $input[ $i ] ); + if ( $n < $valid_range['min'] || $n > $valid_range['max'] ) { + return $input[ $i ]; + } + $valid = false; + foreach ( $valid_range['range'] as $b ) { + if ( $n === $b || is_array( $b ) && $n >= $b[0] && $n <= $b[1] ) { + $valid = true; + break; + } + } + if ( ! $valid ) { + return $input[ $i ]; + } + $i++; + } + + // if there weren't any invalid bytes, return false. + return false; + } + + /** + * Returns true if the input value is found anywhere inside the test value + * (i.e. the inverse of @contains) + * + * @param mixed $input Input. + * @param mixed $test Test. + * @return string|false + */ + public function within( $input, $test ) { + if ( '' === $input || '' === $test ) { + return false; + } + + return stripos( $test, $input ) !== false + ? $input + : false; + } +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-request.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-request.php new file mode 100644 index 00000000..279fd84e --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-request.php @@ -0,0 +1,106 @@ +<?php +/** + * HTTP request representation specific for the WAF. + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +require_once __DIR__ . '/functions.php'; + +/** + * Request representation. + */ +class Waf_Request { + + /** + * Trusted proxies. + * + * @var array List of trusted proxy IP addresses. + */ + private $trusted_proxies = array(); + + /** + * Trusted headers. + * + * @var array List of headers to trust from the trusted proxies. + */ + private $trusted_headers = array(); + + /** + * Sets the list of IP addresses for the proxies to trust. Trusted headers will only be accepted as the + * user IP address from these IP adresses. + * + * Popular choices include: + * - 192.168.0.1 + * - 10.0.0.1 + * + * @param array $proxies List of proxy IP addresses. + * @return void + */ + public function set_trusted_proxies( $proxies ) { + $this->trusted_proxies = (array) $proxies; + } + + /** + * Sets the list of headers to be trusted from the proxies. These headers will only be taken into account + * if the request comes from a trusted proxy as configured with set_trusted_proxies(). + * + * Popular choices include: + * - HTTP_CLIENT_IP + * - HTTP_X_FORWARDED_FOR + * - HTTP_X_FORWARDED + * - HTTP_X_CLUSTER_CLIENT_IP + * - HTTP_FORWARDED_FOR + * - HTTP_FORWARDED + * + * @param array $headers List of HTTP header strings. + * @return void + */ + public function set_trusted_headers( $headers ) { + $this->trusted_headers = (array) $headers; + } + + /** + * Determines the users real IP address based on the settings passed to set_trusted_proxies() and + * set_trusted_headers() before. On CLI, this will be null. + * + * @return string|null + */ + public function get_real_user_ip_address() { + $remote_addr = ! empty( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + + if ( in_array( $remote_addr, $this->trusted_proxies, true ) ) { + $ip_by_header = $this->get_ip_by_header( array_merge( $this->trusted_headers, array( 'REMOTE_ADDR' ) ) ); + if ( ! empty( $ip_by_header ) ) { + return $ip_by_header; + } + } + + return $remote_addr; + } + + /** + * Iterates through a given list of HTTP headers and attempts to get the IP address from the header that + * a proxy sends along. Make sure you trust the IP address before calling this method. + * + * @param array $headers The list of headers to check. + * @return string|null + */ + private function get_ip_by_header( $headers ) { + foreach ( $headers as $key ) { + if ( isset( $_SERVER[ $key ] ) ) { + foreach ( explode( ',', wp_unslash( $_SERVER[ $key ] ) ) as $ip ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- filter_var is applied below. + $ip = trim( $ip ); + + if ( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) { + return $ip; + } + } + } + } + + return null; + } +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-runner.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-runner.php new file mode 100644 index 00000000..389f8b2a --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-runner.php @@ -0,0 +1,469 @@ +<?php +/** + * Entrypoint for actually executing the WAF. + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +use Automattic\Jetpack\Connection\Client; +use Automattic\Jetpack\Modules; +use Jetpack_Options; + +/** + * Executes the WAF. + */ +class Waf_Runner { + + const WAF_RULES_VERSION = '1.0.0'; + const MODE_OPTION_NAME = 'jetpack_waf_mode'; + const IP_LISTS_ENABLED_OPTION_NAME = 'jetpack_waf_ip_list'; + const IP_ALLOW_LIST_OPTION_NAME = 'jetpack_waf_ip_allow_list'; + const IP_BLOCK_LIST_OPTION_NAME = 'jetpack_waf_ip_block_list'; + const RULES_FILE = __DIR__ . '/../rules/rules.php'; + const ALLOW_IP_FILE = __DIR__ . '/../rules/allow-ip.php'; + const BLOCK_IP_FILE = __DIR__ . '/../rules/block-ip.php'; + const VERSION_OPTION_NAME = 'jetpack_waf_rules_version'; + const RULE_LAST_UPDATED_OPTION_NAME = 'jetpack_waf_last_updated_timestamp'; + const SHARE_DATA_OPTION_NAME = 'jetpack_waf_share_data'; + + /** + * Set the mode definition if it has not been set. + * + * @return void + */ + public static function define_mode() { + if ( ! defined( 'JETPACK_WAF_MODE' ) ) { + $mode_option = get_option( self::MODE_OPTION_NAME ); + define( 'JETPACK_WAF_MODE', $mode_option ); + } + } + + /** + * Set the mode definition if it has not been set. + * + * @return void + */ + public static function define_share_data() { + if ( ! defined( 'JETPACK_WAF_SHARE_DATA' ) ) { + $share_data_option = get_option( self::SHARE_DATA_OPTION_NAME, false ); + define( 'JETPACK_WAF_SHARE_DATA', $share_data_option ); + } + } + + /** + * Did the WAF run yet or not? + * + * @return bool + */ + public static function did_run() { + return defined( 'JETPACK_WAF_RUN' ); + } + + /** + * Determines if the passed $option is one of the allowed WAF operation modes. + * + * @param string $option The mode option. + * @return bool + */ + public static function is_allowed_mode( $option ) { + // Normal constants are defined prior to WP_CLI running causing problems for activation + if ( defined( 'WAF_CLI_MODE' ) ) { + $option = WAF_CLI_MODE; + } + + $allowed_modes = array( + 'normal', + 'silent', + ); + + return in_array( $option, $allowed_modes, true ); + } + + /** + * Determines if the WAF module is enabled on the site. + * + * @return bool + */ + public static function is_enabled() { + // if ABSPATH is defined, then WordPress has already been instantiated, + // so we can check to see if the waf module is activated. + if ( defined( 'ABSPATH' ) ) { + return ( new Modules() )->is_active( 'waf' ); + } + + return true; + } + + /** + * Runs the WAF and potentially stops the request if a problem is found. + * + * @return void + */ + public static function run() { + // Make double-sure we are only running once. + if ( self::did_run() ) { + return; + } + + Waf_Constants::initialize_constants(); + + // if ABSPATH is defined, then WordPress has already been instantiated, + // and we're running as a plugin (meh). Otherwise, we're running via something + // like PHP's prepend_file setting (yay!). + define( 'JETPACK_WAF_RUN', defined( 'ABSPATH' ) ? 'plugin' : 'preload' ); + + // if the WAF is being run before a command line script, don't try to execute rules (there's no request). + if ( PHP_SAPI === 'cli' ) { + return; + } + + // if something terrible happens during the WAF running, we don't want to interfere with the rest of the site, + // so we intercept errors ONLY while the WAF is running, then we remove our handler after the WAF finishes. + $display_errors = ini_get( 'display_errors' ); + // phpcs:ignore + ini_set( 'display_errors', 'Off' ); + // phpcs:ignore + set_error_handler( array( self::class, 'errorHandler' ) ); + + try { + + // phpcs:ignore + $waf = new Waf_Runtime( new Waf_Transforms(), new Waf_Operators() ); + + // execute waf rules. + // phpcs:ignore + include self::RULES_FILE; + } catch ( \Exception $err ) { // phpcs:ignore + // Intentionally doing nothing. + } + + // remove the custom error handler, so we don't interfere with the site. + restore_error_handler(); + // phpcs:ignore + ini_set( 'display_errors', $display_errors ); + } + + /** + * Error handler to be used while the WAF is being executed. + * + * @param int $code The error code. + * @param string $message The error message. + * @param string $file File with the error. + * @param string $line Line of the error. + * @return void + */ + public static function errorHandler( $code, $message, $file, $line ) { // phpcs:ignore + // Intentionally doing nothing for now. + } + + /** + * Initializes the WP filesystem. + * + * @return void + * @throws \Exception If filesystem is unavailable. + */ + public static function initialize_filesystem() { + if ( ! function_exists( '\\WP_Filesystem' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + if ( ! \WP_Filesystem() ) { + throw new \Exception( 'No filesystem available.' ); + } + } + + /** + * Activates the WAF by generating the rules script and setting the version + * + * @return void + */ + public static function activate() { + self::define_mode(); + if ( ! self::is_allowed_mode( JETPACK_WAF_MODE ) ) { + return; + } + $version = get_option( self::VERSION_OPTION_NAME ); + if ( ! $version ) { + add_option( self::VERSION_OPTION_NAME, self::WAF_RULES_VERSION ); + } + + add_option( self::SHARE_DATA_OPTION_NAME, true ); + + self::initialize_filesystem(); + self::create_waf_directory(); + self::generate_ip_rules(); + self::create_blocklog_table(); + self::generate_rules(); + } + + /** + * Created the waf directory on activation. + * + * @return void + * @throws \Exception In case there's a problem when creating the directory. + */ + public static function create_waf_directory() { + WP_Filesystem(); + Waf_Constants::initialize_constants(); + + global $wp_filesystem; + if ( ! $wp_filesystem ) { + throw new \Exception( 'Can not work without the file system being initialized.' ); + } + + if ( ! $wp_filesystem->is_dir( JETPACK_WAF_DIR ) ) { + if ( ! $wp_filesystem->mkdir( JETPACK_WAF_DIR ) ) { + throw new \Exception( 'Failed creating WAF standalone bootstrap file directory: ' . JETPACK_WAF_DIR ); + } + } + } + + /** + * Create the log table when plugin is activated. + * + * @return void + */ + public static function create_blocklog_table() { + global $wpdb; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $sql = " + CREATE TABLE {$wpdb->prefix}jetpack_waf_blocklog ( + log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + timestamp datetime NOT NULL, + rule_id BIGINT NOT NULL, + reason longtext NOT NULL, + PRIMARY KEY (log_id), + KEY timestamp (timestamp) + ) + "; + + dbDelta( $sql ); + } + + /** + * Deactivates the WAF by deleting the relevant options and emptying rules file. + * + * @return void + * @throws \Exception If file writing fails. + */ + public static function deactivate() { + delete_option( self::MODE_OPTION_NAME ); + delete_option( self::VERSION_OPTION_NAME ); + + global $wp_filesystem; + + self::initialize_filesystem(); + + if ( ! $wp_filesystem->put_contents( self::RULES_FILE, "<?php\n" ) ) { + throw new \Exception( 'Failed to empty rules.php file.' ); + } + } + + /** + * Tries periodically to update the rules using our API. + * + * @return void + */ + public static function update_rules_cron() { + self::define_mode(); + if ( ! self::is_allowed_mode( JETPACK_WAF_MODE ) ) { + return; + } + + self::generate_rules(); + update_option( self::RULE_LAST_UPDATED_OPTION_NAME, time() ); + } + + /** + * Updates the rule set if rules version has changed + * + * @return void + */ + public static function update_rules_if_changed() { + self::define_mode(); + if ( ! self::is_allowed_mode( JETPACK_WAF_MODE ) ) { + return; + } + $version = get_option( self::VERSION_OPTION_NAME ); + if ( self::WAF_RULES_VERSION !== $version ) { + update_option( self::VERSION_OPTION_NAME, self::WAF_RULES_VERSION ); + self::generate_rules(); + } + } + + /** + * Retrieve rules from the API + * + * @throws \Exception If site is not registered. + * @throws \Exception If API did not respond 200. + * @throws \Exception If data is missing from response. + * @return array + */ + public static function get_rules_from_api() { + $blog_id = Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + throw new \Exception( 'Site is not registered' ); + } + + $response = Client::wpcom_json_api_request_as_blog( + sprintf( '/sites/%s/waf-rules', $blog_id ), + '2', + array(), + null, + 'wpcom' + ); + + $response_code = wp_remote_retrieve_response_code( $response ); + + if ( 200 !== $response_code ) { + throw new \Exception( 'API connection failed.', $response_code ); + } + + $rules_json = wp_remote_retrieve_body( $response ); + $rules = json_decode( $rules_json, true ); + + if ( empty( $rules['data'] ) ) { + throw new \Exception( 'Data missing from response.' ); + } + + return $rules['data']; + } + + /** + * Generates the rules.php script + * + * @throws \Exception If file writing fails. + * @return void + */ + public static function generate_rules() { + /** + * WordPress filesystem abstraction. + * + * @var \WP_Filesystem_Base $wp_filesystem + */ + global $wp_filesystem; + + self::initialize_filesystem(); + + $api_exception = null; + $throw_api_exception = true; + try { + $rules = self::get_rules_from_api(); + } catch ( \Exception $e ) { + if ( 401 === $e->getCode() ) { + // do not throw API exceptions for users who do not have access + $throw_api_exception = false; + } + + if ( $wp_filesystem->exists( self::RULES_FILE ) && $throw_api_exception ) { + throw $e; + } + + $rules = "<?php\n"; + $api_exception = $e; + } + + // Ensure that the folder exists. + if ( ! $wp_filesystem->is_dir( dirname( self::RULES_FILE ) ) ) { + $wp_filesystem->mkdir( dirname( self::RULES_FILE ) ); + } + + $ip_allow_rules = self::ALLOW_IP_FILE; + $ip_block_rules = self::BLOCK_IP_FILE; + + $ip_list_code = "if ( require('$ip_allow_rules') ) { return; }\n" . + "if ( require('$ip_block_rules') ) { return \$waf->block('block', -1, 'ip block list'); }\n"; + + $rules_divided_by_line = explode( "\n", $rules ); + array_splice( $rules_divided_by_line, 1, 0, $ip_list_code ); + + $rules = implode( "\n", $rules_divided_by_line ); + + if ( ! $wp_filesystem->put_contents( self::RULES_FILE, $rules ) ) { + throw new \Exception( 'Failed writing rules file to: ' . self::RULES_FILE ); + } + + if ( null !== $api_exception && $throw_api_exception ) { + throw $api_exception; + } + } + + /** + * We allow for both, one IP per line or comma-; semicolon; or whitespace-separated lists. This also validates the IP addresses + * and only returns the ones that look valid. + * + * @param string $ips List of ips - example: "8.8.8.8\n4.4.4.4,2.2.2.2;1.1.1.1 9.9.9.9,5555.5555.5555.5555". + * @return array List of valid IP addresses. - example based on input example: array('8.8.8.8', '4.4.4.4', '2.2.2.2', '1.1.1.1', '9.9.9.9') + */ + private static function ip_option_to_array( $ips ) { + $ips = (string) $ips; + $ips = preg_split( '/[\s,;]/', $ips ); + + $result = array(); + + foreach ( $ips as $ip ) { + if ( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) { + $result[] = $ip; + } + } + + return $result; + } + + /** + * Generates the rules.php script + * + * @throws \Exception If filesystem is not available. + * @throws \Exception If file writing fails. + * @return void + */ + public static function generate_ip_rules() { + /** + * WordPress filesystem abstraction. + * + * @var \WP_Filesystem_Base $wp_filesystem + */ + global $wp_filesystem; + + self::initialize_filesystem(); + + // Ensure that the folder exists. + if ( ! $wp_filesystem->is_dir( dirname( self::RULES_FILE ) ) ) { + $wp_filesystem->mkdir( dirname( self::RULES_FILE ) ); + } + + $allow_list = self::ip_option_to_array( get_option( self::IP_ALLOW_LIST_OPTION_NAME ) ); + $block_list = self::ip_option_to_array( get_option( self::IP_BLOCK_LIST_OPTION_NAME ) ); + + $lists_enabled = (bool) get_option( self::IP_LISTS_ENABLED_OPTION_NAME ); + if ( false === $lists_enabled ) { + // Making the lists empty effectively disabled the feature while still keeping the other WAF rules evaluation active. + $allow_list = array(); + $block_list = array(); + } + + $allow_rules_content = ''; + // phpcs:disable WordPress.PHP.DevelopmentFunctions + $allow_rules_content .= '$waf_allow_list = ' . var_export( $allow_list, true ) . ";\n"; + // phpcs:enable + $allow_rules_content .= 'return $waf->is_ip_in_array( $waf_allow_list );' . "\n"; + + if ( ! $wp_filesystem->put_contents( self::ALLOW_IP_FILE, "<?php\n$allow_rules_content" ) ) { + throw new \Exception( 'Failed writing allow list file to: ' . self::ALLOW_IP_FILE ); + } + + $block_rules_content = ''; + // phpcs:disable WordPress.PHP.DevelopmentFunctions + $block_rules_content .= '$waf_block_list = ' . var_export( $block_list, true ) . ";\n"; + // phpcs:enable + $block_rules_content .= 'return $waf->is_ip_in_array( $waf_block_list );' . "\n"; + + if ( ! $wp_filesystem->put_contents( self::BLOCK_IP_FILE, "<?php\n$block_rules_content" ) ) { + throw new \Exception( 'Failed writing block list file to: ' . self::BLOCK_IP_FILE ); + } + } +} 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 ) ); + } +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-standalone-bootstrap.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-standalone-bootstrap.php new file mode 100644 index 00000000..26e8f053 --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-standalone-bootstrap.php @@ -0,0 +1,160 @@ +<?php +/** + * Handles generation and deletion of the bootstrap for the standalone WAF mode. + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +use Composer\InstalledVersions; +use Exception; + +/** + * Handles the bootstrap. + */ +class Waf_Standalone_Bootstrap { + + /** + * Ensures that constants are initialized if this class is used. + */ + public function __construct() { + $this->guard_against_missing_abspath(); + $this->initialize_constants(); + } + + /** + * Ensures that this class is not used unless we are in the right context. + * + * @return void + * @throws Exception If we are outside of WordPress. + */ + private function guard_against_missing_abspath() { + + if ( ! defined( 'ABSPATH' ) ) { + throw new Exception( 'Cannot generate the WAF bootstrap if we are not running in WordPress context.' ); + } + } + + /** + * Initializes the constants required for generating the bootstrap, if they have not been initialized yet. + * + * @return void + */ + private function initialize_constants() { + Waf_Constants::initialize_constants(); + } + + /** + * Initialized the WP filesystem and serves as a mocking hook for tests. + * + * @return void + */ + protected function initialize_filesystem() { + if ( ! function_exists( '\\WP_Filesystem' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + WP_Filesystem(); + } + + /** + * Finds the path to the autoloader, which can then be used to require the autoloader in the generated boostrap file. + * + * @return string|null + * @throws Exception In case the autoloader file can not be found. + */ + private function locate_autoloader_file() { + global $jetpack_autoloader_loader; + + $autoload_file = null; + + // Try the Jetpack autoloader. + if ( isset( $jetpack_autoloader_loader ) ) { + $class_file = $jetpack_autoloader_loader->find_class_file( Waf_Runner::class ); + if ( $class_file ) { + $autoload_file = dirname( dirname( dirname( dirname( dirname( $class_file ) ) ) ) ) . '/vendor/autoload.php'; + } + } + + // Try Composer's autoloader. + if ( null === $autoload_file + && is_callable( array( InstalledVersions::class, 'getInstallPath' ) ) + && InstalledVersions::isInstalled( 'automattic/jetpack-waf' ) + ) { + $package_file = InstalledVersions::getInstallPath( 'automattic/jetpack-waf' ); + if ( substr( $package_file, -23 ) === '/automattic/jetpack-waf' ) { + $autoload_file = dirname( dirname( dirname( $package_file ) ) ) . '/vendor/autoload.php'; + } + } + + // Guess. First look for being in a `vendor/automattic/jetpack-waf/src/', then see if we're standalone with our own vendor dir. + if ( null === $autoload_file ) { + $autoload_file = dirname( dirname( dirname( dirname( __DIR__ ) ) ) ) . '/vendor/autoload.php'; + if ( ! file_exists( $autoload_file ) ) { + $autoload_file = dirname( __DIR__ ) . '/vendor/autoload.php'; + } + } + + // Check that the determined file actually exists. + if ( ! file_exists( $autoload_file ) ) { + throw new Exception( 'Can not find autoloader, and the WAF standalone boostrap will not work without it.' ); + } + + return $autoload_file; + } + + /** + * Gets the path to the bootstrap.php file. + * + * @return string The bootstrap.php file path. + */ + public function get_bootstrap_file_path() { + return trailingslashit( JETPACK_WAF_DIR ) . 'bootstrap.php'; + } + + /** + * Generates the bootstrap file. + * + * @return string Absolute path to the bootstrap file. + * @throws Exception In case the file can not be written. + */ + public function generate() { + + $this->initialize_filesystem(); + + global $wp_filesystem; + if ( ! $wp_filesystem ) { + throw new Exception( 'Can not work without the file system being initialized.' ); + } + + $bootstrap_file = $this->get_bootstrap_file_path(); + $mode_option = get_option( Waf_Runner::MODE_OPTION_NAME, false ); + $share_data_option = get_option( Waf_Runner::SHARE_DATA_OPTION_NAME, false ); + + // phpcs:disable WordPress.PHP.DevelopmentFunctions + $code = "<?php\n" + . sprintf( "define( 'DISABLE_JETPACK_WAF', %s );\n", var_export( defined( 'DISABLE_JETPACK_WAF' ) && DISABLE_JETPACK_WAF, true ) ) + . "if ( defined( 'DISABLE_JETPACK_WAF' ) && DISABLE_JETPACK_WAF ) return;\n" + . sprintf( "define( 'JETPACK_WAF_MODE', %s );\n", var_export( $mode_option ? $mode_option : 'silent', true ) ) + . sprintf( "define( 'JETPACK_WAF_SHARE_DATA', %s );\n", var_export( $share_data_option, true ) ) + . sprintf( "define( 'JETPACK_WAF_DIR', %s );\n", var_export( JETPACK_WAF_DIR, true ) ) + . sprintf( "define( 'JETPACK_WAF_WPCONFIG', %s );\n", var_export( JETPACK_WAF_WPCONFIG, true ) ) + . 'require_once ' . var_export( $this->locate_autoloader_file(), true ) . ";\n" + . 'include ' . var_export( dirname( __DIR__ ) . '/run.php', true ) . ";\n"; + // phpcs:enable + + if ( ! $wp_filesystem->is_dir( JETPACK_WAF_DIR ) ) { + if ( ! $wp_filesystem->mkdir( JETPACK_WAF_DIR ) ) { + throw new Exception( 'Failed creating WAF standalone bootstrap file directory: ' . JETPACK_WAF_DIR ); + } + } + + if ( ! $wp_filesystem->put_contents( $bootstrap_file, $code ) ) { + throw new Exception( 'Failed writing WAF standalone bootstrap file to: ' . $bootstrap_file ); + } + + return $bootstrap_file; + } + +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-transforms.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-transforms.php new file mode 100644 index 00000000..a559394f --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/class-waf-transforms.php @@ -0,0 +1,342 @@ +<?php +/** + * Transforms for Jetpack Waf + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +/** + * Waf_Transforms class + */ +class Waf_Transforms { + /** + * Decode a Base64-encoded string. + * + * @param string $value value to be decoded. + * @return string + */ + public function base64_decode( $value ) { + return base64_decode( $value ); + } + + /** + * Remove all characters that might escape a command line command + * + * @see https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-%28v2.x%29#cmdLine + * @param string $value value to be escaped. + * @return string + */ + public function cmd_line( $value ) { + return strtolower( + preg_replace( + '/\s+/', + ' ', + str_replace( + array( ',', ';' ), + ' ', + preg_replace( + '/\s+(?=[\/\(])/', + '', + str_replace( + array( '^', "'", '"', '\\' ), + '', + $value + ) + ) + ) + ) + ); + } + + /** + * Decode a SQL hex string. + * + * @example 414243 decodes to "ABC" + * @param string $value value to be decoded. + * @return string + */ + public function sql_hex_decode( $value ) { + return preg_replace_callback( + '/0x[a-f0-9]+/i', + function ( $matches ) { + $str = substr( $matches[0], 2 ); + if ( 0 !== strlen( $str ) % 2 ) { + $str = '0' . $str; + } + return hex2bin( $str ); + }, + $value + ); + } + + /** + * Encode a string using Base64 encoding. + * + * @param string $value value to be decoded. + * @return string + */ + public function base64_encode( $value ) { + return base64_encode( $value ); + } + + /** + * Convert all whitespace characters to a space and remove any repeated spaces. + * + * @param string $value value to be converted. + * @return string + */ + public function compress_whitespace( $value ) { + return preg_replace( '/\s+/', ' ', $value ); + } + + /** + * Encode string (possibly containing binary characters) by replacing each input byte with two hexadecimal characters. + * + * @param string $value value to be encoded. + * @return string + */ + public function hex_encode( $value ) { + return bin2hex( $value ); + } + + /** + * Decode string that was previously encoded by hexEncode() + * + * @param string $value value to be decoded. + * @return string + */ + public function hex_decode( $value ) { + return pack( 'H*', $value ); + } + + /** + * Decode the characters encoded as HTML entities. + * + * @param mixed $value value do be decoded. + * @return string + */ + public function html_entity_decode( $value ) { + return html_entity_decode( $value ); + } + + /** + * Return the length of the input string. + * + * @param string $value input string. + * @return int + */ + public function length( $value ) { + return strlen( $value ); + } + + /** + * Convert all characters to lowercase. + * + * @param string $value string to be converted. + * @return string + */ + public function lowercase( $value ) { + return strtolower( $value ); + } + + /** + * Calculate an md5 hash for the given data + * + * @param mixed $value value to be hashed. + * @return string + */ + public function md5( $value ) { + return md5( $value, true ); + } + + /** + * Removes multiple slashes, directory self-references, and directory back-references (except when at the beginning of the input) from input string. + * + * @param string $value value to be normalized. + * @return string + */ + public function normalize_path( $value ) { + $parts = explode( + '/', + // replace any duplicate slashes with a single one. + preg_replace( '~/{2,}~', '/', $value ) + ); + + $i = 0; + while ( isset( $parts[ $i ] ) ) { + switch ( $parts[ $i ] ) { + // If this folder is a self-reference, remove it. + case '..': + // If this folder is a backreference, remove it unless we're already at the root. + if ( isset( $parts[ $i - 1 ] ) && ! in_array( $parts[ $i - 1 ], array( '', '..' ), true ) ) { + array_splice( $parts, $i - 1, 2 ); + $i--; + continue 2; + } + break; + case '.': + array_splice( $parts, $i, 1 ); + continue 2; + } + $i++; + } + + return implode( '/', $parts ); + } + + /** + * Convert backslash characters to forward slashes, and then normalize using `normalizePath` + * + * @param string $value to be normalized. + * @return string + */ + public function normalize_path_win( $value ) { + return $this->normalize_path( str_replace( '\\', '/', $value ) ); + } + + /** + * Removes all NUL bytes from input. + * + * @param string $value value to be filtered. + * @return string + */ + public function remove_nulls( $value ) { + return str_replace( "\x0", '', $value ); + } + + /** + * Remove all whitespace characters from input. + * + * @param string $value value to be filtered. + * @return string + */ + public function remove_whitespace( $value ) { + return preg_replace( '/\s/', '', $value ); + } + + /** + * Replaces each occurrence of a C-style comment (/ * ... * /) with a single space. + * Unterminated comments will also be replaced with a space. However, a standalone termination of a comment (* /) will not be acted upon. + * + * @param string $value value to be filtered. + * @return string + */ + public function replace_comments( $value ) { + $value = preg_replace( '~/\*.*?\*/|/\*.*?$~Ds', ' ', $value ); + return explode( '/*', $value, 2 )[0]; + } + + /** + * Removes common comments chars (/ *, * /, --, #). + * + * @param string $value value to be filtered. + * @return string + */ + public function remove_comments_char( $value ) { + return preg_replace( '~/*|*/|--|#|//~', '', $value ); + } + + /** + * Replaces each NUL byte in input with a space. + * + * @param string $value value to be filtered. + * @return string + */ + public function replace_nulls( $value ) { + return str_replace( "\x0", ' ', $value ); + } + + /** + * Decode a URL-encoded input string. + * + * @param string $value value to be decoded. + * @return string + */ + public function url_decode( $value ) { + return urldecode( $value ); + } + + /** + * Decode a URL-encoded input string. + * + * @param string $value value to be decoded. + * @return string + */ + public function url_decode_uni( $value ) { + error_log( 'JETPACKWAF TRANSFORM NOT IMPLEMENTED: urlDecodeUni' ); + return $value; + } + + /** + * Decode a json encoded input string. + * + * @param string $value value to be decoded. + * @return string + */ + public function js_decode( $value ) { + error_log( 'JETPACKWAF TRANSFORM NOT IMPLEMENTED: jsDecode' ); + return $value; + } + + /** + * Convert all characters to uppercase. + * + * @param string $value value to be encoded. + * @return string + */ + public function uppercase( $value ) { + return strtoupper( $value ); + } + + /** + * Calculate a SHA1 hash from the input string. + * + * @param mixed $value value to be hashed. + * @return string + */ + public function sha1( $value ) { + return sha1( $value, true ); + } + + /** + * Remove whitespace from the left side of the input string. + * + * @param string $value value to be trimmed. + * @return string + */ + public function trim_left( $value ) { + return ltrim( $value ); + } + + /** + * Remove whitespace from the right side of the input string. + * + * @param string $value value to be trimmed. + * @return string + */ + public function trim_right( $value ) { + return rtrim( $value ); + } + + /** + * Remove whitespace from both sides of the input string. + * + * @param string $value value to be trimmed. + * @return string + */ + public function trim( $value ) { + return trim( $value ); + } + + /** + * Convert utf-8 characters to unicode characters + * + * @param string $value value to be encoded. + * @return string + */ + public function utf8_to_unicode( $value ) { + return preg_replace( '/\\\u(?=[a-f0-9]{4})/', '%u', substr( json_encode( $value ), 1, -1 ) ); + } +} diff --git a/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/functions.php b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/functions.php new file mode 100644 index 00000000..a8112fd9 --- /dev/null +++ b/plugins/jetpack/jetpack_vendor/automattic/jetpack-waf/src/functions.php @@ -0,0 +1,27 @@ +<?php +/** + * Utility functions for WAF. + * + * @package automattic/jetpack-waf + */ + +namespace Automattic\Jetpack\Waf; + +/** + * A wrapper for WordPress's `wp_unslash()`. + * + * Even though PHP itself dropped the option to add slashes to superglobals a decade ago, + * WordPress still does it through some misguided extreme backwards compatibility. 🙄 + * + * If WordPress's function exists, assume it needs to be called. If not, assume it doesn't. + * + * @param string|array $value String or array of data to unslash. + * @return string|array Possibly unslashed $value. + */ +function wp_unslash( $value ) { + if ( function_exists( '\\wp_unslash' ) ) { + return \wp_unslash( $value ); + } else { + return $value; + } +} |