Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.76% covered (warning)
64.76%
68 / 105
38.46% covered (danger)
38.46%
5 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Waf_Rules_Manager
64.76% covered (warning)
64.76%
68 / 105
38.46% covered (danger)
38.46%
5 / 13
96.90
0.00% covered (danger)
0.00%
0 / 1
 automatic_rules_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ip_allow_list_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ip_block_list_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_hooks
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 schedule_rules_cron
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 update_rules_cron
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 reactivate_on_rules_option_change
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 update_rules_if_changed
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 get_rules_from_api
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 wrap_require
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generate_rules
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
10.27
 generate_automatic_rules
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 generate_ip_rules
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
5.23
1<?php
2/**
3 * Class for generating and working with firewall rule files.
4 *
5 * @since 0.9.0
6 *
7 * @package automattic/jetpack-waf
8 */
9
10namespace Automattic\Jetpack\Waf;
11
12use Automattic\Jetpack\Connection\Client;
13use Automattic\Jetpack\IP\Utils as IP_Utils;
14use Jetpack_Options;
15use WP_Error;
16
17/**
18 * Class for generating and working with firewall rule files.
19 */
20class Waf_Rules_Manager {
21
22    const RULES_VERSION = '1.0.0';
23
24    // WAF Options
25    const VERSION_OPTION_NAME                      = 'jetpack_waf_rules_version';
26    const AUTOMATIC_RULES_ENABLED_OPTION_NAME      = 'jetpack_waf_automatic_rules';
27    const IP_ALLOW_LIST_OPTION_NAME                = 'jetpack_waf_ip_allow_list';
28    const IP_ALLOW_LIST_ENABLED_OPTION_NAME        = 'jetpack_waf_ip_allow_list_enabled';
29    const IP_BLOCK_LIST_OPTION_NAME                = 'jetpack_waf_ip_block_list';
30    const IP_BLOCK_LIST_ENABLED_OPTION_NAME        = 'jetpack_waf_ip_block_list_enabled';
31    const RULE_LAST_UPDATED_OPTION_NAME            = 'jetpack_waf_last_updated_timestamp';
32    const AUTOMATIC_RULES_LAST_UPDATED_OPTION_NAME = 'jetpack_waf_automatic_rules_last_updated_timestamp';
33
34    /**
35     * IP Lists Enabled Option Name
36     *
37     * @deprecated 0.17.0 Use Waf_Rules_Manager::IP_ALLOW_LIST_ENABLED_OPTION_NAME and Waf_Rules_Manager::IP_BLOCK_LIST_ENABLED_OPTION_NAME instead.
38     */
39    const IP_LISTS_ENABLED_OPTION_NAME = 'jetpack_waf_ip_list';
40
41    // Rule Files
42    const AUTOMATIC_RULES_FILE = '/rules/automatic-rules.php';
43    const IP_ALLOW_RULES_FILE  = '/rules/allow-ip.php';
44    const IP_BLOCK_RULES_FILE  = '/rules/block-ip.php';
45
46    /**
47     * Rules Entrypoint File
48     *
49     * @deprecated 0.22.0 Use JETPACK_WAF_ENTRYPOINT instead.
50     */
51    const RULES_ENTRYPOINT_FILE = '/rules/rules.php';
52
53    /**
54     * Whether automatic rules are enabled.
55     *
56     * @return bool
57     */
58    public static function automatic_rules_enabled() {
59        return (bool) get_option( self::AUTOMATIC_RULES_ENABLED_OPTION_NAME );
60    }
61
62    /**
63     * Whether IP allow list is enabled.
64     *
65     * @return bool
66     */
67    public static function ip_allow_list_enabled() {
68        return (bool) get_option( self::IP_ALLOW_LIST_ENABLED_OPTION_NAME );
69    }
70
71    /**
72     * Whether IP block list is enabled.
73     *
74     * @return bool
75     */
76    public static function ip_block_list_enabled() {
77        return (bool) get_option( self::IP_BLOCK_LIST_ENABLED_OPTION_NAME );
78    }
79
80    /**
81     * Register WordPress hooks for the WAF rules.
82     *
83     * @return void
84     */
85    public static function add_hooks() {
86        // Re-activate the WAF any time an option is added or updated.
87        add_action( 'add_option_' . self::AUTOMATIC_RULES_ENABLED_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
88        add_action( 'update_option_' . self::AUTOMATIC_RULES_ENABLED_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
89        add_action( 'add_option_' . self::IP_ALLOW_LIST_ENABLED_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
90        add_action( 'update_option_' . self::IP_ALLOW_LIST_ENABLED_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
91        add_action( 'add_option_' . self::IP_ALLOW_LIST_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
92        add_action( 'update_option_' . self::IP_ALLOW_LIST_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
93        add_action( 'add_option_' . self::IP_BLOCK_LIST_ENABLED_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
94        add_action( 'update_option_' . self::IP_BLOCK_LIST_ENABLED_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
95        add_action( 'add_option_' . self::IP_BLOCK_LIST_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
96        add_action( 'update_option_' . self::IP_BLOCK_LIST_OPTION_NAME, array( static::class, 'reactivate_on_rules_option_change' ), 10, 0 );
97        // Register the cron job.
98        add_action( 'jetpack_waf_rules_update_cron', array( static::class, 'update_rules_cron' ) );
99    }
100
101    /**
102     * Schedule the cron job to update the WAF rules.
103     *
104     * @return bool|WP_Error True if the event is scheduled, WP_Error on failure.
105     */
106    public static function schedule_rules_cron() {
107        if ( ! wp_next_scheduled( 'jetpack_waf_rules_update_cron' ) ) {
108            return wp_schedule_event( time(), 'twicedaily', 'jetpack_waf_rules_update_cron', array(), true );
109        }
110
111        return true;
112    }
113
114    /**
115     * Tries periodically to update the rules using our API.
116     *
117     * @return bool|WP_Error True if rules update is successful, WP_Error on failure.
118     */
119    public static function update_rules_cron() {
120        try {
121            self::generate_automatic_rules();
122            self::generate_ip_rules();
123            self::generate_rules();
124        } catch ( Waf_Exception $e ) {
125            return $e->get_wp_error();
126        }
127
128        update_option( self::RULE_LAST_UPDATED_OPTION_NAME, time() );
129        return true;
130    }
131
132    /**
133     * Re-activate the WAF any time an option is added or updated.
134     *
135     * @return bool|WP_Error True if re-activation is successful, WP_Error on failure.
136     */
137    public static function reactivate_on_rules_option_change() {
138        try {
139            Waf_Runner::activate();
140        } catch ( Waf_Exception $e ) {
141            return $e->get_wp_error();
142        }
143
144        return true;
145    }
146
147    /**
148     * Updates the rule set if rules version has changed
149     *
150     * @throws Waf_Exception If the firewall mode is invalid.
151     * @throws Waf_Exception If the rules update fails.
152     *
153     * @return void
154     */
155    public static function update_rules_if_changed() {
156        $version = get_option( self::VERSION_OPTION_NAME );
157        if ( self::RULES_VERSION !== $version ) {
158            self::generate_automatic_rules();
159            self::generate_ip_rules();
160            self::generate_rules();
161
162            update_option( self::VERSION_OPTION_NAME, self::RULES_VERSION );
163        }
164    }
165
166    /**
167     * Retrieve rules from the API
168     *
169     * @throws Waf_Exception       If site is not registered.
170     * @throws Rules_API_Exception If API did not respond 200.
171     * @throws Rules_API_Exception If data is missing from response.
172     *
173     * @return array
174     */
175    public static function get_rules_from_api() {
176        $blog_id = Jetpack_Options::get_option( 'id' );
177        if ( ! $blog_id ) {
178            throw new Waf_Exception( 'Site is not registered' );
179        }
180
181        $response = Client::wpcom_json_api_request_as_blog(
182            sprintf( '/sites/%s/waf-rules', $blog_id ),
183            '2',
184            array(),
185            null,
186            'wpcom'
187        );
188
189        $response_code = wp_remote_retrieve_response_code( $response );
190
191        if ( 200 !== $response_code ) {
192            throw new Rules_API_Exception( 'API connection failed.', (int) $response_code );
193        }
194
195        $rules_json = wp_remote_retrieve_body( $response );
196        $rules      = json_decode( $rules_json, true );
197
198        if ( empty( $rules['data'] ) ) {
199            throw new Rules_API_Exception( 'Data missing from response.' );
200        }
201
202        return $rules['data'];
203    }
204
205    /**
206     * Wraps a require statement in a file_exists check.
207     *
208     * @param string $required_file The file to check if exists and require.
209     * @param string $return_code   The PHP code to execute if the file require returns true. Defaults to 'return;'.
210     *
211     * @return string The wrapped require statement.
212     */
213    private static function wrap_require( $required_file, $return_code = 'return;' ) {
214        return "if ( file_exists( '$required_file' ) ) { if ( require( '$required_file' ) ) { $return_code } }";
215    }
216
217    /**
218     * Generates the rules.php script
219     *
220     * @global \WP_Filesystem_Base $wp_filesystem WordPress filesystem abstraction.
221     *
222     * @throws File_System_Exception If file writing fails initializing rule files.
223     * @throws File_System_Exception If file writing fails writing to the rules entrypoint file.
224     *
225     * @return void
226     */
227    public static function generate_rules() {
228        global $wp_filesystem;
229        Waf_Runner::initialize_filesystem();
230        Waf_Constants::define_entrypoint();
231
232        $rules                = "<?php\n";
233        $entrypoint_file_path = Waf_Runner::get_waf_file_path( JETPACK_WAF_ENTRYPOINT );
234
235        // Ensure that the folder exists
236        if ( ! $wp_filesystem->is_dir( dirname( $entrypoint_file_path ) ) ) {
237            $wp_filesystem->mkdir( dirname( $entrypoint_file_path ) );
238        }
239
240        // Ensure all potentially required rule files exist
241        $rule_files = array( JETPACK_WAF_ENTRYPOINT, self::AUTOMATIC_RULES_FILE, self::IP_ALLOW_RULES_FILE, self::IP_BLOCK_RULES_FILE );
242        foreach ( $rule_files as $rule_file ) {
243            $rule_file = Waf_Runner::get_waf_file_path( $rule_file );
244            if ( ! $wp_filesystem->is_file( $rule_file ) ) {
245                if ( ! $wp_filesystem->put_contents( $rule_file, "<?php\n" ) ) {
246                    throw new File_System_Exception( 'Failed writing rules file to: ' . $rule_file );
247                }
248            }
249        }
250
251        // Add IP allow list
252        if ( self::ip_allow_list_enabled() ) {
253            $rules .= self::wrap_require( Waf_Runner::get_waf_file_path( self::IP_ALLOW_RULES_FILE ) ) . "\n";
254        }
255
256        // Add IP block list
257        if ( self::ip_block_list_enabled() ) {
258            $rules .= self::wrap_require( Waf_Runner::get_waf_file_path( self::IP_BLOCK_RULES_FILE ), "return \$waf->block( 'block', -1, 'ip block list' );" ) . "\n";
259        }
260
261        // Add automatic rules
262        if ( self::automatic_rules_enabled() ) {
263            $rules .= self::wrap_require( Waf_Runner::get_waf_file_path( self::AUTOMATIC_RULES_FILE ) ) . "\n";
264        }
265
266        // Update the rules file
267        if ( ! $wp_filesystem->put_contents( $entrypoint_file_path, $rules ) ) {
268            throw new File_System_Exception( 'Failed writing rules file to: ' . $entrypoint_file_path );
269        }
270    }
271
272    /**
273     * Generates the automatic-rules.php script
274     *
275     * @global \WP_Filesystem_Base $wp_filesystem WordPress filesystem abstraction.
276     *
277     * @throws Waf_Exception         If rules cannot be fetched from the API.
278     * @throws File_System_Exception If file writing fails.
279     *
280     * @return void
281     */
282    public static function generate_automatic_rules() {
283        global $wp_filesystem;
284        Waf_Runner::initialize_filesystem();
285
286        $automatic_rules_file_path = Waf_Runner::get_waf_file_path( self::AUTOMATIC_RULES_FILE );
287
288        // Ensure that the folder exists.
289        if ( ! $wp_filesystem->is_dir( dirname( $automatic_rules_file_path ) ) ) {
290            $wp_filesystem->mkdir( dirname( $automatic_rules_file_path ) );
291        }
292
293        try {
294            $rules = self::get_rules_from_api();
295        } catch ( Waf_Exception $e ) {
296            // Do not throw API exceptions for users who do not have access
297            if ( 401 !== $e->getCode() ) {
298                throw $e;
299            }
300        }
301
302        // If there are no rules available, don't overwrite the existing file.
303        if ( empty( $rules ) ) {
304            return;
305        }
306
307        if ( ! $wp_filesystem->put_contents( $automatic_rules_file_path, $rules ) ) {
308            throw new File_System_Exception( 'Failed writing automatic rules file to: ' . $automatic_rules_file_path );
309        }
310
311        update_option( self::AUTOMATIC_RULES_LAST_UPDATED_OPTION_NAME, time() );
312    }
313
314    /**
315     * Generates the rules.php script
316     *
317     * @global \WP_Filesystem_Base $wp_filesystem WordPress filesystem abstraction.
318     *
319     * @throws File_System_Exception If writing to IP allow list file fails.
320     * @throws File_System_Exception If writing to IP block list file fails.
321     *
322     * @return void
323     */
324    public static function generate_ip_rules() {
325        global $wp_filesystem;
326        Waf_Runner::initialize_filesystem();
327
328        $allow_ip_file_path = Waf_Runner::get_waf_file_path( self::IP_ALLOW_RULES_FILE );
329        $block_ip_file_path = Waf_Runner::get_waf_file_path( self::IP_BLOCK_RULES_FILE );
330
331        // Ensure that the folders exists.
332        if ( ! $wp_filesystem->is_dir( dirname( $allow_ip_file_path ) ) ) {
333            $wp_filesystem->mkdir( dirname( $allow_ip_file_path ) );
334        }
335        if ( ! $wp_filesystem->is_dir( dirname( $block_ip_file_path ) ) ) {
336            $wp_filesystem->mkdir( dirname( $block_ip_file_path ) );
337        }
338
339        $allow_list = IP_Utils::get_ip_addresses_from_string( get_option( self::IP_ALLOW_LIST_OPTION_NAME ) );
340        $block_list = IP_Utils::get_ip_addresses_from_string( get_option( self::IP_BLOCK_LIST_OPTION_NAME ) );
341
342        $allow_rules_content = '';
343        // phpcs:disable WordPress.PHP.DevelopmentFunctions
344        $allow_rules_content .= '$waf_allow_list = ' . var_export( $allow_list, true ) . ";\n";
345        // phpcs:enable
346        $allow_rules_content .= 'return $waf->is_ip_in_array( $waf_allow_list );' . "\n";
347
348        if ( ! $wp_filesystem->put_contents( $allow_ip_file_path, "<?php\n$allow_rules_content" ) ) {
349            throw new File_System_Exception( 'Failed writing allow list file to: ' . $allow_ip_file_path );
350        }
351
352        $block_rules_content = '';
353        // phpcs:disable WordPress.PHP.DevelopmentFunctions
354        $block_rules_content .= '$waf_block_list = ' . var_export( $block_list, true ) . ";\n";
355        // phpcs:enable
356        $block_rules_content .= 'return $waf->is_ip_in_array( $waf_block_list );' . "\n";
357
358        if ( ! $wp_filesystem->put_contents( $block_ip_file_path, "<?php\n$block_rules_content" ) ) {
359            throw new File_System_Exception( 'Failed writing block list file to: ' . $block_ip_file_path );
360        }
361    }
362}