Skip to content

Commit

Permalink
WAF: Add a dedicated automatic-rules.php file for storing firewall ru…
Browse files Browse the repository at this point in the history
…les (#28027)
  • Loading branch information
nateweller authored Jan 4, 2023
1 parent f068bbc commit fbc1e6b
Show file tree
Hide file tree
Showing 12 changed files with 219 additions and 105 deletions.
4 changes: 4 additions & 0 deletions projects/packages/waf/changelog/add-waf-automatic-rules-file
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Changed where automatic firewall rules are stored.
30 changes: 30 additions & 0 deletions projects/packages/waf/src/class-compatibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,39 @@ class Waf_Compatibility {
* @return void
*/
public static function add_compatibility_hooks() {
add_filter( 'default_option_' . Waf_Runner::AUTOMATIC_RULES_ENABLED_OPTION_NAME, __CLASS__ . '::default_option_waf_automatic_rules', 10, 3 );
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 automatic rules option was introduced.
*
* @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_automatic_rules( $default, $option, $passed_default ) {
// Allow get_option() to override this default value
if ( $passed_default ) {
return $default;
}

return self::get_default_automatic_rules_option();
}

/**
* If the option is not available, use the WAF module status
* to determine whether or not to run automatic rules
*/
public static function get_default_automatic_rules_option() {
return Waf_Runner::is_enabled();
}

/**
* Provides a default value for sites that installed the WAF
* before the NEEDS_UPDATE_OPTION_NAME option was added.
Expand Down
1 change: 1 addition & 0 deletions projects/packages/waf/src/class-rest-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public static function update_rules() {
$message = 'Rules updated succesfully';

try {
Waf_Runner::generate_automatic_rules();
Waf_Runner::generate_rules();
} catch ( \Exception $e ) {
$success = false;
Expand Down
3 changes: 2 additions & 1 deletion projects/packages/waf/src/class-waf-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ public function teardown() {
*/
public function generate_rules() {
try {
Waf_Runner::generate_automatic_rules();
Waf_Runner::generate_rules();
} catch ( \Exception $e ) {

Expand All @@ -158,7 +159,7 @@ public function generate_rules() {
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 )
Waf_Runner::get_waf_file_path( Waf_Runner::RULES_ENTRYPOINT_FILE )
)
);
}
Expand Down
192 changes: 112 additions & 80 deletions projects/packages/waf/src/class-waf-runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,25 @@
*/
class Waf_Runner {

const WAF_MODULE_NAME = 'waf';
const WAF_RULES_VERSION = '1.0.0';
const MODE_OPTION_NAME = 'jetpack_waf_mode';
const AUTOMATIC_RULES_ENABLED_OPTION_NAME = 'jetpack_waf_automatic_rules';
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';
const WAF_MODULE_NAME = 'waf';
const WAF_RULES_VERSION = '1.0.0';

// WAF Options
const MODE_OPTION_NAME = 'jetpack_waf_mode';
const AUTOMATIC_RULES_ENABLED_OPTION_NAME = 'jetpack_waf_automatic_rules';
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 VERSION_OPTION_NAME = 'jetpack_waf_rules_version';
const RULE_LAST_UPDATED_OPTION_NAME = 'jetpack_waf_last_updated_timestamp';
const AUTOMATIC_RULES_LAST_UPDATED_OPTION_NAME = 'jetpack_waf_automatic_rules_last_updated_timestamp';
const SHARE_DATA_OPTION_NAME = 'jetpack_waf_share_data';

// Rule Files
const RULES_ENTRYPOINT_FILE = '/rules/rules.php';
const AUTOMATIC_RULES_FILE = '/rules/automatic-rules.php';
const IP_ALLOW_RULES_FILE = '/rules/allow-ip.php';
const IP_BLOCK_RULES_FILE = '/rules/block-ip.php';

/**
* Run the WAF
Expand Down Expand Up @@ -58,9 +64,14 @@ public static function initialize() {
* @return void
*/
public static function add_hooks() {
// Re-activate the WAF any time an option is added or updated.
add_action( 'add_option_' . self::AUTOMATIC_RULES_ENABLED_OPTION_NAME, array( static::class, 'activate' ), 10, 0 );
add_action( 'update_option_' . self::AUTOMATIC_RULES_ENABLED_OPTION_NAME, array( static::class, 'activate' ), 10, 0 );
add_action( 'add_option_' . self::IP_LISTS_ENABLED_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( 'add_option_' . self::IP_ALLOW_LIST_OPTION_NAME, array( static::class, 'activate' ), 10, 0 );
add_action( 'update_option_' . self::IP_ALLOW_LIST_OPTION_NAME, array( static::class, 'activate' ), 10, 0 );
add_action( 'add_option_' . self::IP_BLOCK_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( 'jetpack_waf_rules_update_cron', array( static::class, 'update_rules_cron' ) );
// TODO: This doesn't exactly fit here - may need to find another home
Expand Down Expand Up @@ -164,24 +175,6 @@ public static function is_enabled() {
return true;
}

/**
* Determines if automatic rules are enabled.
*
* @return bool
*/
public static function automatic_rules_enabled() {
// for backwards compatibility, if the automatic rules option does not exist and the
// module is active, consider automatic rules enabled
$option_exists = get_option( self::AUTOMATIC_RULES_ENABLED_OPTION_NAME ) === false;
if ( ! $option_exists && self::is_enabled() ) {
$is_enabled = true;
} else {
$is_enabled = (bool) get_option( self::AUTOMATIC_RULES_ENABLED_OPTION_NAME );
}

return $is_enabled;
}

/**
* Enables the WAF module on the site.
*/
Expand Down Expand Up @@ -209,6 +202,7 @@ public static function get_config() {
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(),
'automatic_rules_available' => (bool) get_option( self::AUTOMATIC_RULES_LAST_UPDATED_OPTION_NAME ),
);
}

Expand Down Expand Up @@ -276,7 +270,7 @@ public static function run() {
$waf = new Waf_Runtime( new Waf_Transforms(), new Waf_Operators() );

// execute waf rules.
$rules_file_path = self::get_waf_file_path( self::RULES_FILE );
$rules_file_path = self::get_waf_file_path( self::RULES_ENTRYPOINT_FILE );
if ( file_exists( $rules_file_path ) ) {
// phpcs:ignore
include $rules_file_path;
Expand Down Expand Up @@ -335,12 +329,11 @@ public static function activate() {
add_option( self::VERSION_OPTION_NAME, self::WAF_RULES_VERSION );
}

add_option( self::AUTOMATIC_RULES_ENABLED_OPTION_NAME, false );
add_option( self::IP_LISTS_ENABLED_OPTION_NAME, false );
add_option( self::SHARE_DATA_OPTION_NAME, true );

self::initialize_filesystem();
self::create_waf_directory();
self::generate_automatic_rules();
self::generate_ip_rules();
self::create_blocklog_table();
self::generate_rules();
Expand Down Expand Up @@ -406,7 +399,7 @@ public static function deactivate() {

self::initialize_filesystem();

if ( ! $wp_filesystem->put_contents( self::get_waf_file_path( self::RULES_FILE ), "<?php\n" ) ) {
if ( ! $wp_filesystem->put_contents( self::get_waf_file_path( self::RULES_ENTRYPOINT_FILE ), "<?php\n" ) ) {
throw new \Exception( 'Failed to empty rules.php file.' );
}
}
Expand All @@ -422,6 +415,7 @@ public static function update_rules_cron() {
return;
}

self::generate_automatic_rules();
self::generate_ip_rules();
self::generate_rules();
update_option( self::RULE_LAST_UPDATED_OPTION_NAME, time() );
Expand All @@ -440,6 +434,7 @@ public static function update_rules_if_changed() {
$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_automatic_rules();
self::generate_ip_rules();
self::generate_rules();
}
Expand Down Expand Up @@ -493,6 +488,17 @@ public static function get_rules_from_api() {
return $rules['data'];
}

/**
* Wraps a require statement in a file_exists check.
*
* @param string $required_file The file to check if exists and require.
* @param string $return_code The PHP code to execute if the file require returns true. Defaults to 'return;'.
* @return string The wrapped require statement.
*/
private static function wrap_require( $required_file, $return_code = 'return;' ) {
return "if ( file_exists( '$required_file' ) ) { if ( require( '$required_file' ) ) { $return_code } }";
}

/**
* Generates the rules.php script
*
Expand All @@ -509,53 +515,84 @@ public static function generate_rules() {

self::initialize_filesystem();

$rules_file_path = self::get_waf_file_path( self::RULES_FILE );
$rules = "<?php\n";
$entrypoint_file_path = self::get_waf_file_path( self::RULES_ENTRYPOINT_FILE );

$api_exception = null;
$throw_api_exception = true;

// Ensure that the folder exists.
if ( ! $wp_filesystem->is_dir( dirname( $rules_file_path ) ) ) {
$wp_filesystem->mkdir( dirname( $rules_file_path ) );
// Ensure that the folder exists
if ( ! $wp_filesystem->is_dir( dirname( $entrypoint_file_path ) ) ) {
$wp_filesystem->mkdir( dirname( $entrypoint_file_path ) );
}

// Add automatic rules
if ( self::automatic_rules_enabled() ) {
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;
// Ensure all potentially required rule files exist
$rule_files = array( self::RULES_ENTRYPOINT_FILE, self::AUTOMATIC_RULES_FILE, self::IP_ALLOW_RULES_FILE, self::IP_BLOCK_RULES_FILE );
foreach ( $rule_files as $rule_file ) {
$rule_file = self::get_waf_file_path( $rule_file );
if ( ! $wp_filesystem->is_file( $rule_file ) ) {
if ( ! $wp_filesystem->put_contents( $rule_file, "<?php\n" ) ) {
throw new \Exception( 'Failed writing rules file to: ' . $rule_file );
}

$api_exception = $e;
}
}

// Add manual rules
$ip_allow_rules = self::get_waf_file_path( self::ALLOW_IP_FILE );
$ip_block_rules = self::get_waf_file_path( self::BLOCK_IP_FILE );
if ( get_option( self::IP_LISTS_ENABLED_OPTION_NAME ) ) {
$rules .= self::wrap_require( self::get_waf_file_path( self::IP_ALLOW_RULES_FILE ) ) . "\n";
$rules .= self::wrap_require( self::get_waf_file_path( self::IP_BLOCK_RULES_FILE ), "return \$waf->block( 'block', -1, 'ip block list' );" ) . "\n";
}

// Add automatic rules
if ( get_option( self::AUTOMATIC_RULES_ENABLED_OPTION_NAME ) ) {
$rules .= self::wrap_require( self::get_waf_file_path( self::AUTOMATIC_RULES_FILE ) ) . "\n";
}

// Update the rules file
if ( ! $wp_filesystem->put_contents( $entrypoint_file_path, $rules ) ) {
throw new \Exception( 'Failed writing rules file to: ' . $entrypoint_file_path );
}
}

$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";
/**
* Generates the automatic-rules.php script
*
* @throws \Exception If rules cannot be generated and saved.
* @return void
*/
public static function generate_automatic_rules() {
/**
* WordPress filesystem abstraction.
*
* @var \WP_Filesystem_Base $wp_filesystem
*/
global $wp_filesystem;

$rules_divided_by_line = explode( "\n", $rules );
array_splice( $rules_divided_by_line, 1, 0, $ip_list_code );
self::initialize_filesystem();

$rules = implode( "\n", $rules_divided_by_line );
$automatic_rules_file_path = self::get_waf_file_path( self::AUTOMATIC_RULES_FILE );

if ( ! $wp_filesystem->put_contents( $rules_file_path, $rules ) ) {
throw new \Exception( 'Failed writing rules file to: ' . $rules_file_path );
// Ensure that the folder exists.
if ( ! $wp_filesystem->is_dir( dirname( $automatic_rules_file_path ) ) ) {
$wp_filesystem->mkdir( dirname( $automatic_rules_file_path ) );
}

if ( null !== $api_exception && $throw_api_exception ) {
throw $api_exception;
try {
$rules = self::get_rules_from_api();
} catch ( \Exception $exception ) {
// Do not throw API exceptions for users who do not have access
if ( 401 !== $exception->getCode() ) {
throw $exception;
}
}

// If there are no rules available, don't overwrite the existing file.
if ( empty( $rules ) ) {
return;
}

if ( ! $wp_filesystem->put_contents( $automatic_rules_file_path, $rules ) ) {
throw new \Exception( 'Failed writing automatic rules file to: ' . $automatic_rules_file_path );
}

update_option( self::AUTOMATIC_RULES_LAST_UPDATED_OPTION_NAME, time() );
}

/**
Expand Down Expand Up @@ -597,25 +634,20 @@ public static function generate_ip_rules() {

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 );
$allow_ip_file_path = self::get_waf_file_path( self::IP_ALLOW_RULES_FILE );
$block_ip_file_path = self::get_waf_file_path( self::IP_BLOCK_RULES_FILE );

// Ensure that the folder exists.
if ( ! $wp_filesystem->is_dir( dirname( $rules_file_path ) ) ) {
$wp_filesystem->mkdir( dirname( $rules_file_path ) );
// Ensure that the folders exists.
if ( ! $wp_filesystem->is_dir( dirname( $allow_ip_file_path ) ) ) {
$wp_filesystem->mkdir( dirname( $allow_ip_file_path ) );
}
if ( ! $wp_filesystem->is_dir( dirname( $block_ip_file_path ) ) ) {
$wp_filesystem->mkdir( dirname( $block_ip_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";
Expand Down
4 changes: 4 additions & 0 deletions projects/plugins/jetpack/changelog/update-waf-tests-2
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: compat

Update WAF tests.
5 changes: 5 additions & 0 deletions projects/plugins/jetpack/tests/e2e/helpers/waf-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ export async function enableAutomaticRules() {
logger.sync( 'Enabling automatic firewall rules' );
return execWpCommand( 'option update jetpack_waf_automatic_rules 1' );
}

export async function generateRules() {
logger.sync( 'Generating firewall rules' );
return execWpCommand( 'jetpack-waf generate_rules' );
}
Loading

0 comments on commit fbc1e6b

Please sign in to comment.