updated plugin Jetpack Protect version 1.2.0

This commit is contained in:
2023-01-18 16:40:00 +00:00
committed by Gitium
parent 6f8f73e860
commit 35a7ea2e06
117 changed files with 5567 additions and 665 deletions

View File

@ -0,0 +1,50 @@
<?php
/**
* Class used to manage backwards-compatibility of the package.
*
* @since 0.8.0
*
* @package automattic/jetpack-waf
*/
namespace Automattic\Jetpack\Waf;
/**
* Defines methods for ensuring backwards compatibility.
*/
class Waf_Compatibility {
/**
* Add compatibilty hooks
*
* @since 0.8.0
*
* @return void
*/
public static function add_compatibility_hooks() {
add_filter( 'default_option_' . Waf_Initializer::NEEDS_UPDATE_OPTION_NAME, __CLASS__ . '::default_option_waf_needs_update', 10, 3 );
}
/**
* Provides a default value for sites that installed the WAF
* before the NEEDS_UPDATE_OPTION_NAME option was added.
*
* @since 0.8.0
*
* @param mixed $default The default value to return if the option does not exist in the database.
* @param string $option Option name.
* @param bool $passed_default Was get_option() passed a default value.
*
* @return mixed The default value to return if the option does not exist in the database.
*/
public static function default_option_waf_needs_update( $default, $option, $passed_default ) {
// Allow get_option() to override this default value
if ( $passed_default ) {
return $default;
}
// If the option hasn't been added yet, the WAF needs to be updated.
return true;
}
}

View File

@ -0,0 +1,130 @@
<?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_Error;
use WP_REST_Server;
/**
* Defines our endponts.
*/
class REST_Controller {
/**
* Register REST API endpoints.
*/
public static function register_rest_routes() {
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',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => __CLASS__ . '::update_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( Waf_Runner::get_config() );
}
/**
* Update WAF Endpoint
*
* @param WP_REST_Request $request The API request.
* @return WP_REST_Response
*/
public static function update_waf( $request ) {
// IP Lists Enabled
if ( isset( $request[ Waf_Runner::IP_LISTS_ENABLED_OPTION_NAME ] ) ) {
update_option( Waf_Runner::IP_LISTS_ENABLED_OPTION_NAME, (bool) $request->get_param( Waf_Runner::IP_LISTS_ENABLED_OPTION_NAME ) );
}
// IP Block List
if ( isset( $request[ Waf_Runner::IP_BLOCK_LIST_OPTION_NAME ] ) ) {
update_option( Waf_Runner::IP_BLOCK_LIST_OPTION_NAME, $request[ Waf_Runner::IP_BLOCK_LIST_OPTION_NAME ] );
}
// IP Allow List
if ( isset( $request[ Waf_Runner::IP_ALLOW_LIST_OPTION_NAME ] ) ) {
update_option( Waf_Runner::IP_ALLOW_LIST_OPTION_NAME, $request[ Waf_Runner::IP_ALLOW_LIST_OPTION_NAME ] );
}
// Share Data
if ( isset( $request[ Waf_Runner::SHARE_DATA_OPTION_NAME ] ) ) {
update_option( Waf_Runner::SHARE_DATA_OPTION_NAME, (bool) $request[ Waf_Runner::SHARE_DATA_OPTION_NAME ] );
}
Waf_Runner::update_waf();
return self::waf();
}
/**
* 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( 'manage_options' ) ) {
return true;
}
return new WP_Error(
'invalid_user_permission_manage_options',
REST_Connector::get_user_permissions_error_msg(),
array( 'status' => rest_authorization_required_code() )
);
}
}

View File

@ -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::get_waf_file_path( Waf_Runner::RULES_FILE )
)
);
}
}

View File

@ -0,0 +1,34 @@
<?php
/**
* Class use to define the constants used by the WAF
*
* @package automattic/jetpack-waf
*/
namespace Automattic\Jetpack\Waf;
use Automattic\Jetpack\Status\Host;
/**
* 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' );
}
if ( ! defined( 'DISABLE_JETPACK_WAF' ) ) {
$is_wpcom = defined( 'IS_WPCOM' ) && IS_WPCOM;
$is_atomic = ( new Host() )->is_atomic_platform();
define( 'DISABLE_JETPACK_WAF', $is_wpcom || $is_atomic );
}
}
}

View File

@ -0,0 +1,141 @@
<?php
/**
* Class use to initialize the WAF module.
*
* @package automattic/jetpack-waf
*/
namespace Automattic\Jetpack\Waf;
/**
* Initializes the module
*/
class Waf_Initializer {
/**
* Option for storing whether or not the WAF files are potentially out of date.
*
* @var string NEEDS_UPDATE_OPTION_NAME
*/
const NEEDS_UPDATE_OPTION_NAME = 'jetpack_waf_needs_update';
/**
* Initializes the configurations needed for the waf module.
*
* @return void
*/
public static function init() {
// Do not run in unsupported environments
add_action( 'jetpack_get_available_modules', __CLASS__ . '::remove_module_on_unsupported_environments' );
if ( ! Waf_Runner::is_supported_environment() ) {
return;
}
// Update the WAF after installing or upgrading a relevant Jetpack plugin
add_action( 'upgrader_process_complete', __CLASS__ . '::update_waf_after_plugin_upgrade', 10, 2 );
add_action( 'admin_init', __CLASS__ . '::check_for_waf_update' );
// Activation/Deactivation hooks
add_action( 'jetpack_activate_module_waf', __CLASS__ . '::on_activation' );
add_action( 'jetpack_deactivate_module_waf', __CLASS__ . '::on_deactivation' );
// Ensure backwards compatibility
Waf_Compatibility::add_compatibility_hooks();
// Run the WAF
Waf_Runner::initialize();
}
/**
* 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();
}
/**
* Updates the WAF after upgrader process is complete.
*
* @param WP_Upgrader $upgrader WP_Upgrader instance. In other contexts this might be a Theme_Upgrader, Plugin_Upgrader, Core_Upgrade, or Language_Pack_Upgrader instance.
* @param array $hook_extra Array of bulk item update data.
*
* @return void
*/
public static function update_waf_after_plugin_upgrade( $upgrader, $hook_extra ) {
$jetpack_text_domains_with_waf = array( 'jetpack', 'jetpack-protect' );
$jetpack_plugins_with_waf = array( 'jetpack/jetpack.php', 'jetpack-protect/jetpack-protect.php' );
// Only run on upgrades affecting plugins
if ( 'plugin' !== $hook_extra['type'] ) {
return;
}
// Only run on updates and installations
if ( 'update' !== $hook_extra['action'] && 'install' !== $hook_extra['action'] ) {
return;
}
// Only run when Jetpack plugins were affected
if ( 'update' === $hook_extra['action'] &&
! empty( $hook_extra['plugins'] ) &&
empty( array_intersect( $jetpack_plugins_with_waf, $hook_extra['plugins'] ) )
) {
return;
}
if ( 'install' === $hook_extra['action'] &&
! empty( $upgrader->new_plugin_data['TextDomain'] ) &&
empty( in_array( $upgrader->new_plugin_data['TextDomain'], $jetpack_text_domains_with_waf, true ) )
) {
return;
}
update_option( self::NEEDS_UPDATE_OPTION_NAME, 1 );
}
/**
* Check for WAF update
*
* Updates the WAF when the "needs update" option is enabled.
*
* @return void
*/
public static function check_for_waf_update() {
if ( get_option( self::NEEDS_UPDATE_OPTION_NAME ) ) {
Waf_Runner::define_mode();
if ( ! Waf_Runner::is_allowed_mode( JETPACK_WAF_MODE ) ) {
return;
}
Waf_Runner::generate_ip_rules();
Waf_Runner::generate_rules();
( new Waf_Standalone_Bootstrap() )->generate();
update_option( self::NEEDS_UPDATE_OPTION_NAME, 0 );
}
}
/**
* Disables the WAF module when on an supported platform.
*
* @param array $modules Filterable value for `jetpack_get_available_modules`.
*
* @return array Array of module slugs.
*/
public static function remove_module_on_unsupported_environments( $modules ) {
if ( ! Waf_Runner::is_supported_environment() ) {
// WAF should never be available on unsupported platforms.
unset( $modules['waf'] );
}
return $modules;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,329 @@
<?php
/**
* HTTP request representation specific for the WAF.
*
* @package automattic/jetpack-waf
*/
namespace Automattic\Jetpack\Waf;
require_once __DIR__ . '/functions.php';
/**
* Request representation.
*
* @template RequestFile as array{ name: string, filename: string }
*/
class Waf_Request {
/**
* The request URL, broken into three pieces: the host, the filename, and the query string
*
* @example for `https://wordpress.com/index.php?myvar=red`
* $this->url = [ 'https://wordpress.com', '/index.php', '?myvar=red' ]
* @var array{ 0: string, 1: string, 2: string }|null
*/
protected $url = null;
/**
* 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;
}
/**
* Returns the headers that were sent with this request
*
* @return array{ 0: string, 1: scalar }[]
*/
public function get_headers() {
$value = array();
$has_content_type = false;
$has_content_length = false;
foreach ( $_SERVER as $k => $v ) {
$k = strtolower( $k );
if ( 'http_' === substr( $k, 0, 5 ) ) {
$value[] = array( $this->normalize_header_name( substr( $k, 5 ) ), $v );
} elseif ( 'content_type' === $k && '' !== $v ) {
$has_content_type = true;
$value[] = array( 'content-type', $v );
} elseif ( 'content_length' === $k && '' !== $v ) {
$has_content_length = true;
$value[] = array( 'content-length', $v );
}
}
if ( ! $has_content_type ) {
// default Content-Type per RFC 7231 section 3.1.5.5.
$value[] = array( 'content-type', 'application/octet-stream' );
}
if ( ! $has_content_length ) {
$value[] = array( 'content-length', '0' );
}
return $value;
}
/**
* Change a header name to all-lowercase and replace spaces and underscores with dashes.
*
* @param string $name The header name to normalize.
* @return string
*/
public function normalize_header_name( $name ) {
return str_replace( array( ' ', '_' ), '-', strtolower( $name ) );
}
/**
* Get the method for this request (GET, POST, etc).
*
* @return string
*/
public function get_method() {
return isset( $_SERVER['REQUEST_METHOD'] )
? filter_var( wp_unslash( $_SERVER['REQUEST_METHOD'] ), FILTER_DEFAULT )
: '';
}
/**
* Get the protocol for this request (HTTP, HTTPS, etc)
*
* @return string
*/
public function get_protocol() {
return isset( $_SERVER['SERVER_PROTOCOL'] )
? filter_var( wp_unslash( $_SERVER['SERVER_PROTOCOL'] ), FILTER_DEFAULT )
: '';
}
/**
* Returns the URL parts for this request.
*
* @see $this->url
* @return array{ 0: string, 1: string, 2: string }
*/
protected function get_url() {
if ( null !== $this->url ) {
return $this->url;
}
$uri = isset( $_SERVER['REQUEST_URI'] ) ? filter_var( wp_unslash( $_SERVER['REQUEST_URI'] ), FILTER_DEFAULT ) : '/';
if ( false !== strpos( $uri, '?' ) ) {
// remove the query string (we'll pull it from elsewhere later)
$uri = substr( $uri, 0, strpos( $uri, '?' ) );
}
$query_string = isset( $_SERVER['QUERY_STRING'] ) ? '?' . filter_var( wp_unslash( $_SERVER['QUERY_STRING'] ), FILTER_DEFAULT ) : '';
if ( 1 === preg_match( '/^https?:\/\//', $uri ) ) {
// sometimes $_SERVER[REQUEST_URI] already includes the full domain name
$uri_host = substr( $uri, 0, strpos( $uri, '/', 8 ) );
$uri_path = substr( $uri, strlen( $uri_host ) );
$this->url = array( $uri_host, $uri_path, $query_string );
} else {
// otherwise build the URI manually
$uri_scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'on' === $_SERVER['HTTPS'] )
? 'https'
: 'http';
$uri_host = isset( $_SERVER['HTTP_HOST'] )
? filter_var( wp_unslash( $_SERVER['HTTP_HOST'] ), FILTER_DEFAULT )
: (
isset( $_SERVER['SERVER_NAME'] )
? filter_var( wp_unslash( $_SERVER['SERVER_NAME'] ), FILTER_DEFAULT )
: ''
);
$uri_port = isset( $_SERVER['SERVER_PORT'] )
? filter_var( wp_unslash( $_SERVER['SERVER_PORT'] ), FILTER_SANITIZE_NUMBER_INT )
: '';
// we only need to include the port if it's non-standard
if ( $uri_port && ( 'http' === $uri_scheme && '80' !== $uri_port || 'https' === $uri_scheme && '443' !== $uri_port ) ) {
$uri_port = ':' . $uri_port;
} else {
$uri_port = '';
}
$this->url = array(
$uri_scheme . '://' . $uri_host . $uri_port,
$uri,
$query_string,
);
}
return $this->url;
}
/**
* Get the requested URI
*
* @param boolean $include_host If true, the scheme and domain will be included in the returned string (i.e. 'https://wordpress.com/index.php).
* If false, only the requested URI path will be returned (i.e. '/index.php').
* @return string
*/
public function get_uri( $include_host = false ) {
list( $host, $file, $query ) = $this->get_url();
return ( $include_host ? $host : '' ) . $file . $query;
}
/**
* Return the filename part of the request
*
* @example for 'https://wordpress.com/some/page?id=5', return '/some/page'
* @return string
*/
public function get_filename() {
return $this->get_url()[1];
}
/**
* Return the query string. If present, it will be prefixed with '?'. Otherwise, it will be an empty string.
*
* @return string
*/
public function get_query_string() {
return $this->get_url()[2];
}
/**
* Returns the request body.
*
* @return string
*/
public function get_body() {
$body = file_get_contents( 'php://input' );
return false === $body ? '' : $body;
}
/**
* Returns the cookies
*
* @return array<string, string>
*/
public function get_cookies() {
return flatten_array( $_COOKIE );
}
/**
* Returns the GET variables
*
* @return array<string, mixed|array>
*/
public function get_get_vars() {
return flatten_array( $_GET );
}
/**
* Returns the POST variables
*
* @return array<string, mixed|array>
*/
public function get_post_vars() {
return flatten_array( $_POST );
}
/**
* Returns the files that were uploaded with this request (i.e. what's in the $_FILES superglobal)
*
* @return RequestFile[]
*/
public function get_files() {
$files = array();
foreach ( $_FILES as $field_name => $arr ) {
// flatten the values in case we were given inputs with brackets
foreach ( flatten_array( $arr ) as list( $arr_key, $arr_value ) ) {
if ( $arr_key === 'name' ) {
// if this file was a simple (non-nested) name and unique, then just add it.
$files[] = array(
'name' => $field_name,
'filename' => $arr_value,
);
} elseif ( 'name[' === substr( $arr_key, 0, 5 ) ) {
// otherwise this was a file with a nested name and/or multiple files with the same name
$files[] = array(
'name' => $field_name . substr( $arr_key, 4 ),
'filename' => $arr_value,
);
}
}
}
return $files;
}
}

View File

@ -0,0 +1,612 @@
<?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 Automattic\Jetpack\Status\Host;
use Jetpack_Options;
/**
* Executes the WAF.
*/
class Waf_Runner {
const WAF_MODULE_NAME = 'waf';
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 = '/rules/rules.php';
const ALLOW_IP_FILE = '/rules/allow-ip.php';
const BLOCK_IP_FILE = '/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';
/**
* Run the WAF
*/
public static function initialize() {
if ( ! self::is_enabled() ) {
return;
}
self::define_mode();
self::define_share_data();
if ( ! self::is_allowed_mode( JETPACK_WAF_MODE ) ) {
return;
}
// Don't run if in standalone mode
if ( function_exists( 'add_action' ) ) {
self::add_hooks();
}
if ( ! self::did_run() ) {
self::run();
}
}
/**
* Set action hooks
*
* @return void
*/
public static function add_hooks() {
add_action( 'update_option_' . self::IP_ALLOW_LIST_OPTION_NAME, array( static::class, 'activate' ), 10, 0 );
add_action( 'update_option_' . self::IP_BLOCK_LIST_OPTION_NAME, array( static::class, 'activate' ), 10, 0 );
add_action( 'update_option_' . self::IP_LISTS_ENABLED_OPTION_NAME, array( static::class, 'activate' ), 10, 0 );
add_action( 'jetpack_waf_rules_update_cron', array( static::class, 'update_rules_cron' ) );
// TODO: This doesn't exactly fit here - may need to find another home
if ( ! wp_next_scheduled( 'jetpack_waf_rules_update_cron' ) ) {
wp_schedule_event( time(), 'twicedaily', 'jetpack_waf_rules_update_cron' );
}
// Register REST routes.
add_action( 'rest_api_init', array( new REST_Controller(), 'register_rest_routes' ) );
}
/**
* 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 is supported in the current environment.
*
* @since 0.8.0
* @return bool
*/
public static function is_supported_environment() {
// Do not run when killswitch is enabled
if ( defined( 'DISABLE_JETPACK_WAF' ) && DISABLE_JETPACK_WAF ) {
return false;
}
// Do not run in the WPCOM context
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return false;
}
// Do not run on the Atomic platform
if ( ( new Host() )->is_atomic_platform() ) {
return false;
}
return 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( self::WAF_MODULE_NAME );
}
return true;
}
/**
* Enables the WAF module on the site.
*/
public static function enable() {
return ( new Modules() )->activate( self::WAF_MODULE_NAME, false, false );
}
/**
* Disabled the WAF module on the site.
*/
public static function disable() {
return ( new Modules() )->deactivate( self::WAF_MODULE_NAME );
}
/**
* Get Config
*
* @return array The WAF settings and current configuration data.
*/
public static function get_config() {
return array(
self::IP_LISTS_ENABLED_OPTION_NAME => get_option( self::IP_LISTS_ENABLED_OPTION_NAME ),
self::IP_ALLOW_LIST_OPTION_NAME => get_option( self::IP_ALLOW_LIST_OPTION_NAME ),
self::IP_BLOCK_LIST_OPTION_NAME => get_option( self::IP_BLOCK_LIST_OPTION_NAME ),
self::SHARE_DATA_OPTION_NAME => get_option( self::SHARE_DATA_OPTION_NAME ),
'bootstrap_path' => self::get_bootstrap_file_path(),
);
}
/**
* 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();
}
/**
* Get WAF File Path
*
* @param string $file The file path starting in the WAF directory.
* @return string The full file path to the provided file in the WAF directory.
*/
public static function get_waf_file_path( $file ) {
Waf_Constants::initialize_constants();
// Ensure the file path starts with a slash.
if ( '/' !== substr( $file, 0, 1 ) ) {
$file = "/$file";
}
return JETPACK_WAF_DIR . $file;
}
/**
* 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.
$rules_file_path = self::get_waf_file_path( self::RULES_FILE );
if ( file_exists( $rules_file_path ) ) {
// phpcs:ignore
include $rules_file_path;
}
} 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::get_waf_file_path( 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_ip_rules();
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_ip_rules();
self::generate_rules();
}
}
/**
* Handle updates to the WAF
*/
public static function update_waf() {
self::update_rules_if_changed();
// Re-generate the standalone bootstrap file on every update
// TODO: We may consider only doing this when the WAF version changes
( new Waf_Standalone_Bootstrap() )->generate();
}
/**
* 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();
$rules_file_path = self::get_waf_file_path( self::RULES_FILE );
$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( $rules_file_path ) && $throw_api_exception ) {
throw $e;
}
$rules = "<?php\n";
$api_exception = $e;
}
// Ensure that the folder exists.
if ( ! $wp_filesystem->is_dir( dirname( $rules_file_path ) ) ) {
$wp_filesystem->mkdir( dirname( $rules_file_path ) );
}
$ip_allow_rules = self::get_waf_file_path( self::ALLOW_IP_FILE );
$ip_block_rules = self::get_waf_file_path( self::BLOCK_IP_FILE );
$ip_list_code = "if ( file_exists( '$ip_allow_rules' ) ) { if ( require( '$ip_allow_rules' ) ) { return; } }\n" .
"if ( file_exists( '$ip_block_rules' ) ) { 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( $rules_file_path, $rules ) ) {
throw new \Exception( 'Failed writing rules file to: ' . $rules_file_path );
}
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();
$rules_file_path = self::get_waf_file_path( self::RULES_FILE );
$allow_ip_file_path = self::get_waf_file_path( self::ALLOW_IP_FILE );
$block_ip_file_path = self::get_waf_file_path( self::BLOCK_IP_FILE );
// Ensure that the folder exists.
if ( ! $wp_filesystem->is_dir( dirname( $rules_file_path ) ) ) {
$wp_filesystem->mkdir( dirname( $rules_file_path ) );
}
$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( $allow_ip_file_path, "<?php\n$allow_rules_content" ) ) {
throw new \Exception( 'Failed writing allow list file to: ' . $allow_ip_file_path );
}
$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( $block_ip_file_path, "<?php\n$block_rules_content" ) ) {
throw new \Exception( 'Failed writing block list file to: ' . $block_ip_file_path );
}
}
}

View File

@ -0,0 +1,783 @@
<?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
*
* @template Target as array{ only?: string[], except?: string[], count?: boolean }
* @template TargetBag as array<string, Target>
*/
class Waf_Runtime {
/**
* If used, normalize_array_targets() will just return the number of matching values, instead of the values themselves.
*/
const NORMALIZE_ARRAY_COUNT = 1;
/**
* If used, normalize_array_targets() will apply "only" and "except" filters to the values of the source array, instead of the keys.
*/
const NORMALIZE_ARRAY_MATCH_VALUES = 2;
/**
* 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;
/**
* The request
*
* @var Waf_Request
*/
private $request;
/**
* 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.
* @param Waf_Request? $request Information about the request.
*/
public function __construct( $transforms, $operators, $request = null ) {
$this->transforms = $transforms;
$this->operators = $operators;
$this->request = null === $request
? new Waf_Request()
: $request;
}
/**
* 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 TargetBag $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;
} elseif (
! 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 ] );
}
/**
* A cache of metadata about the incoming request.
*
* @param string $key The type of metadata to request ('headers', 'request_method', etc.).
*/
public function meta( $key ) {
if ( ! isset( $this->metadata[ $key ] ) ) {
$value = null;
switch ( $key ) {
case 'headers':
$value = $this->request->get_headers();
break;
case 'headers_names':
$value = $this->args_names( $this->meta( 'headers' ) );
break;
case 'request_method':
$value = $this->request->get_method();
break;
case 'request_protocol':
$value = $this->request->get_protocol();
break;
case 'request_uri':
$value = $this->request->get_uri( false );
break;
case 'request_uri_raw':
$value = $this->request->get_uri( true );
break;
case 'request_filename':
$value = $this->request->get_filename();
break;
case 'request_line':
$value = sprintf(
'%s %s %s',
$this->request->get_method(),
$this->request->get_uri( false ),
$this->request->get_protocol()
);
break;
case 'request_basename':
$value = basename( $this->request->get_filename() );
break;
case 'request_body':
$value = $this->request->get_body();
break;
case 'query_string':
$value = $this->request->get_query_string();
break;
case 'args_get':
$value = $this->request->get_get_vars();
break;
case 'args_get_names':
$value = $this->args_names( $this->meta( 'args_get' ) );
break;
case 'args_post':
$value = $this->request->get_post_vars();
break;
case 'args_post_names':
$value = $this->args_names( $this->meta( 'args_post' ) );
break;
case 'args':
$value = array_merge( $this->meta( 'args_get' ), $this->meta( 'args_post' ) );
break;
case 'args_names':
$value = $this->args_names( $this->meta( 'args' ) );
break;
case 'request_cookies':
$value = $this->request->get_cookies();
break;
case 'request_cookies_names':
$value = $this->args_names( $this->meta( 'request_cookies' ) );
break;
case 'files':
$value = array();
foreach ( $this->request->get_files() as $f ) {
$value[] = array( $f['name'], $f['filename'] );
}
break;
case 'files_names':
$value = $this->args_names( $this->meta( 'files' ) );
break;
}
$this->metadata[ $key ] = $value;
}
return $this->metadata[ $key ];
}
/**
* 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 ) );
}
/**
* Get match-able values from a collection of targets.
*
* This function expects an associative array of target items, and returns an array of possible values from those targets that can be used to match against.
* The key is the lowercase target name (i.e. `args`, `request_headers`, etc) - see https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v3.x)#Variables
* The value is an associative array of options that define how to narrow down the returned values for that target if it's an array (ARGS, for example). The possible options are:
* count: If `true`, then the returned value will a count of how many matched targets were found, rather then the actual values of those targets.
* For example, &ARGS_GET will return the number of keys the query string.
* only: If specified, then only values in that target that match the given key will be returned.
* For example, ARGS_GET:id|ARGS_GET:/^name/ will only return the values for `$_GET['id']` and any key in `$_GET` that starts with `name`
* except: If specified, then values in that target will be left out from the returned values (even if they were included in an `only` option)
* For example, ARGS_GET|!ARGS_GET:z will return every value from `$_GET` except for `$_GET['z']`.
*
* This function will return an array of associative arrays. Each with:
* name: The target name that this value came from (i.e. the key in the input `$targets` argument )
* source: For targets that are associative arrays (like ARGS), this will be the target name AND the key in that target (i.e. "args:z" for ARGS:z)
* value: The value that was found in the associated target.
*
* @param TargetBag $targets An assoc. array with keys that are target name(s) and values are options for how to process that target (include/exclude rules, whether to return values or counts).
* @return array{ name: string, source: string, value: mixed }
*/
public function normalize_targets( $targets ) {
$return = array();
foreach ( $targets as $k => $v ) {
$count_only = isset( $v['count'] ) ? self::NORMALIZE_ARRAY_COUNT : 0;
$only = isset( $v['only'] ) ? $v['only'] : array();
$except = isset( $v['except'] ) ? $v['except'] : array();
$_k = strtolower( $k );
switch ( $_k ) {
case 'request_headers':
$this->normalize_array_target(
// get the headers that came in with this request
$this->meta( 'headers' ),
// ensure only and exclude filters are normalized
array_map( array( $this->request, 'normalize_header_name' ), $only ),
array_map( array( $this->request, 'normalize_header_name' ), $except ),
$k,
$return,
// flags
$count_only
);
continue 2;
case 'request_headers_names':
$this->normalize_array_target( $this->meta( 'headers_names' ), $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
continue 2;
case 'request_method':
case 'request_protocol':
case 'request_uri':
case 'request_uri_raw':
case 'request_filename':
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':
case 'args':
case 'args_get':
case 'args_post':
case 'files':
$this->normalize_array_target( $this->meta( $_k ), $only, $except, $k, $return, $count_only );
continue 2;
case 'request_cookies_names':
case 'args_names':
case 'args_get_names':
case 'args_post_names':
case 'files_names':
// get the "full" data (for 'args_names' get data for 'args') and stripe it down to just the key names
$data = array_map(
function ( $item ) {
return $item[0]; },
$this->meta( substr( $_k, 0, -6 ) )
);
$this->normalize_array_target( $data, $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
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 ) {
$real_ip = $this->request->get_real_user_ip_address();
return in_array( $real_ip, $array, true );
}
/**
* Extract values from an associative array, potentially applying filters and/or counting results.
*
* @param array{ 0: string, 1: scalar }|scalar[] $source The source assoc. array of values (i.e. $_GET, $_SERVER, etc.).
* @param string[] $only Only include the values for these keys in the output.
* @param string[] $excl Never include the values for these keys in the output.
* @param string $name The name of this target (see https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v3.x)#Variables).
* @param array $results Array to add output values to, will be modified by this method.
* @param int $flags Any of the NORMALIZE_ARRAY_* constants defined at the top of the class.
*/
private function normalize_array_target( $source, $only, $excl, $name, &$results, $flags = 0 ) {
$output = array();
$has_only = isset( $only[0] );
$has_excl = isset( $excl[0] );
foreach ( $source as $source_key => $source_val ) {
if ( is_array( $source_val ) ) {
// if $source_val looks like a tuple from flatten_array(), then use the tuple as the key and value
$source_key = $source_val[0];
$source_val = $source_val[1];
}
$filter_match = ( $flags & self::NORMALIZE_ARRAY_MATCH_VALUES ) > 0 ? $source_val : $source_key;
// if this key is on the "exclude" list, skip it
if ( $has_excl && $this->key_matches( $filter_match, $excl ) ) {
continue;
}
// if this key isn't in our "only" list, then skip it
if ( $has_only && ! $this->key_matches( $filter_match, $only ) ) {
continue;
}
// otherwise add this key/value to our output
$output[] = array( $source_key, $source_val );
}
if ( ( $flags & self::NORMALIZE_ARRAY_COUNT ) > 0 ) {
// If we've been told to just count the values, then just count them.
$results[] = array(
'name' => (string) $name,
'value' => count( $output ),
'source' => '&' . $name,
);
} else {
foreach ( $output as list( $item_name, $item_value ) ) {
$results[] = array(
'name' => (string) $item_name,
'value' => $item_value,
'source' => "$name:$item_name",
);
}
}
return $results;
}
/**
* Given an array of tuples - probably from flatten_array() - return a new array
* consisting of only the first value (the key name) from each tuple.
*
* @param array{0:string, 1:scalar}[] $flat_array An array of tuples.
* @return string[]
*/
private function args_names( $flat_array ) {
$names = array_map(
function ( $tuple ) {
return $tuple[0];
},
$flat_array
);
return array_unique( $names );
}
/**
* Return whether or not a given $input key matches one of the given $patterns.
*
* @param string $input Key name to test against patterns.
* @param string[] $patterns Patterns to test key name with.
* @return bool
*/
private function key_matches( $input, $patterns ) {
foreach ( $patterns as $p ) {
if ( '/' === $p[0] ) {
if ( 1 === preg_match( $p, $input ) ) {
return true;
}
} elseif ( 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 ) );
}
}

View File

@ -0,0 +1,164 @@
<?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.
*
* Should only be implemented after the wp_loaded action hook:
*
* @link https://developer.wordpress.org/reference/functions/wp_filesystem/#more-information
*
* @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"
. "Automattic\Jetpack\Waf\Waf_Runner::initialize();\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;
}
}

View File

@ -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, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
}
/**
* 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 ) );
}
}

View File

@ -0,0 +1,70 @@
<?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;
}
}
/**
* PHP helpfully parses request data into nested arrays in superglobals like $_GET and $_POST,
* and as part of that parsing turns field names like "myfield[x][y]" into a nested array
* that looks like [ "myfield" => [ "x" => [ "y" => "..." ] ] ]
* However, modsecurity (and thus our WAF rules) expect the original (non-nested) names.
*
* Therefore, this method takes an array of any depth and returns a single-depth array with nested
* keys translated back to a single string with brackets.
*
* Because there might be multiple items with the same name, this function will return an array of tuples,
* with the first item in the tuple the re-created original field name, and the second item the value.
*
* @example
* flatten_array( [ "field1" => "abc", "field2" => [ "d", "e", "f" ] ] )
* => [
* [ "field1", "abc" ],
* [ "field2[0]", "d" ],
* [ "field2[1]", "e" ],
* [ "field2[2]", "f" ],
* ]
*
* @param array $array An array that resembles one of the PHP superglobals like $_GET or $_POST.
* @param string $key_prefix String that should be prepended to the keys output by this function.
* Usually only used internally as part of recursion when flattening a nested array.
* @return array{ 0: string, 1: scalar }[] $key_prefix An array of key/value tuples, one for each distinct value in the input array.
*/
function flatten_array( $array, $key_prefix = '' ) {
$return = array();
foreach ( $array as $source_key => $source_value ) {
$key = ( '' === $key_prefix )
// if this is the first level, the key name isn't enclosed in brackets
? $source_key
// for every level after the first, enclose the key name in brackets.
: $key_prefix . '[' . $source_key . ']';
if ( ! is_array( $source_value ) ) {
$return[] = array( $key, $source_value );
} else {
$return = array_merge( $return, flatten_array( $source_value, $key ) );
}
}
return $return;
}