Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.74% covered (warning)
56.74%
80 / 141
38.46% covered (danger)
38.46%
5 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Firewall
57.55% covered (warning)
57.55%
80 / 139
38.46% covered (danger)
38.46%
5 / 13
277.00
0.00% covered (danger)
0.00%
0 / 1
 get_key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_state
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 register_routes
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 start_test
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 get_blocks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 get_daily_blocks
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
7.93
 get_test_summary
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
10
 get_rules_updated_at
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_current_ip
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 validate_ip_block_list
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 ip_list_covers
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 get_ip_rules
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 add_request_details
n/a
0 / 0
n/a
0 / 0
1
 get_recent_blocks
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2/**
3 * Protect dashboard: the Firewall section.
4 *
5 * @package automattic/jetpack-protect
6 */
7
8namespace Automattic\Jetpack\Protect\Sections;
9
10use Automattic\Jetpack\IP\Utils as IP_Utils;
11use Automattic\Jetpack\Protect\Dashboard;
12use Automattic\Jetpack\Protect\Dashboard_Section;
13use Automattic\Jetpack\Waf\Waf_Blocklog_Manager;
14use Automattic\Jetpack\Waf\Waf_Request;
15use Automattic\Jetpack\Waf\Waf_Rules_Manager;
16use Automattic\Jetpack\Waf\Waf_Runner;
17use Automattic\Jetpack\Waf\Waf_Self_Check;
18use Automattic\Jetpack\Waf\Waf_Stats;
19use WP_Error;
20use WP_REST_Server;
21
22if ( ! defined( 'ABSPATH' ) ) {
23    exit( 0 );
24}
25
26/**
27 * The firewall's on/off state, blocked requests, IP lists and a one-time request to test it with.
28 *
29 * @since 0.2.0
30 */
31class Firewall implements Dashboard_Section {
32
33    /**
34     * Headers a proxy names the visitor in. One that disagrees with the firewall's address means that address is the proxy's.
35     */
36    const FORWARDING_HEADERS = array(
37        'HTTP_X_FORWARDED_FOR',
38        'HTTP_X_REAL_IP',
39        'HTTP_CF_CONNECTING_IP',
40        'HTTP_TRUE_CLIENT_IP',
41        'HTTP_CLIENT_IP',
42    );
43
44    /**
45     * The key the section's state is printed under.
46     *
47     * @return string
48     */
49    public function get_key() {
50        return 'firewall';
51    }
52
53    /**
54     * The firewall module's state, its blocked requests and whether a Scan plan is active.
55     *
56     * @return array
57     */
58    public function get_state() {
59        // The block counts persist while the firewall is off, so they are sent either way.
60        return array_merge(
61            Dashboard::get_module_state( 'waf' ),
62            self::get_blocks(),
63            array(
64                'hasScan'        => Dashboard::has_scan_plan(),
65                'upgradeUrl'     => Dashboard::get_scan_upgrade_url(),
66                'currentIp'      => self::get_current_ip(),
67                'ipRules'        => self::get_ip_rules(),
68                'rulesUpdatedAt' => self::get_rules_updated_at(),
69                'providedByHost' => method_exists( Waf_Runner::class, 'is_provided_by_host' ) && Waf_Runner::is_provided_by_host(),
70            )
71        );
72    }
73
74    /**
75     * Register `jetpack/v4/protect-dashboard/firewall/test` and `…/firewall/blocks`.
76     *
77     * The firewall's settings are saved through existing endpoints.
78     *
79     * @return void
80     */
81    public function register_routes() {
82        register_rest_route(
83            'jetpack/v4',
84            '/protect-dashboard/firewall/test',
85            array(
86                'methods'             => WP_REST_Server::CREATABLE,
87                'callback'            => array( __CLASS__, 'start_test' ),
88                'permission_callback' => array( Dashboard::class, 'can_manage' ),
89            )
90        );
91
92        register_rest_route(
93            'jetpack/v4',
94            '/protect-dashboard/firewall/blocks',
95            array(
96                'methods'             => WP_REST_Server::READABLE,
97                'callback'            => array( __CLASS__, 'get_blocks' ),
98                'permission_callback' => array( Dashboard::class, 'can_manage' ),
99            )
100        );
101    }
102
103    /**
104     * A one-time URL the firewall blocks, for the browser to request.
105     *
106     * @return array|WP_Error
107     */
108    public static function start_test() {
109        // The WAF package bundled by another plugin can predate the self-check.
110        if ( ! class_exists( Waf_Self_Check::class ) ) {
111            return new WP_Error(
112                'firewall_test_unavailable',
113                __( 'Update Jetpack to test the firewall.', 'jetpack-protect-pkg' ),
114                array( 'status' => 501 )
115            );
116        }
117
118        $token = Waf_Self_Check::create_token();
119        if ( ! $token ) {
120            return new WP_Error(
121                'firewall_test_failed',
122                __( 'The firewall test couldn’t start.', 'jetpack-protect-pkg' ),
123                array( 'status' => 500 )
124            );
125        }
126
127        return array( 'url' => add_query_arg( Waf_Self_Check::QUERY_PARAM, $token, home_url( '/' ) ) );
128    }
129
130    /**
131     * The recent blocks and the block counts, refreshed after a test.
132     *
133     * The last 30 days are UTC days, as the firewall records them.
134     *
135     * @return array
136     */
137    public static function get_blocks() {
138        $daily = self::get_daily_blocks();
139        $all   = method_exists( Waf_Blocklog_Manager::class, 'get_all_time_block_count' ) ? Waf_Blocklog_Manager::get_all_time_block_count() : null;
140
141        return array_merge(
142            array(
143                'recentBlocks'      => self::get_recent_blocks(),
144                'blockedCount'      => $all,
145                // Not `Waf_Stats`' `thirty_days`, which spans 31 dates.
146                'blockedLast30Days' => $daily ? array_sum( array_column( $daily, 'count' ) ) : null,
147            ),
148            self::get_test_summary()
149        );
150    }
151
152    /**
153     * Blocked requests on each of the last 30 UTC days, oldest first, with 0 for a day without any.
154     *
155     * @return array<int, array{date: string, count: int}> Empty when the WAF package isn't loaded.
156     */
157    private static function get_daily_blocks() {
158        if ( ! class_exists( Waf_Blocklog_Manager::class ) ) {
159            return array();
160        }
161
162        if ( method_exists( Waf_Blocklog_Manager::class, 'get_daily_block_counts' ) ) {
163            $counts = Waf_Blocklog_Manager::get_daily_block_counts();
164        } else {
165            // An older WAF package: read it uncached, as `Waf_Blocklog_Manager::get_stored_option()` does.
166            global $wpdb;
167            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
168            $stored = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->base_prefix}options WHERE option_name = %s", Waf_Blocklog_Manager::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY ) );
169            $counts = null === $stored ? array() : maybe_unserialize( $stored );
170            $counts = is_array( $counts ) ? $counts : array();
171        }
172
173        $days = array();
174        for ( $ago = 29; $ago >= 0; $ago-- ) {
175            $date   = gmdate( 'Y-m-d', strtotime( "-$ago days" ) );
176            $days[] = array(
177                'date'  => $date,
178                'count' => isset( $counts[ $date ] ) ? (int) $counts[ $date ] : 0,
179            );
180        }
181
182        return $days;
183    }
184
185    /**
186     * Whether the firewall has blocked anything besides its own tests, and when it was last tested.
187     *
188     * The log covers blocks from before the firewall recorded whether any weren't tests, until they're pruned.
189     *
190     * @return array{hasBlocked: bool, lastTestAt: string|null}
191     */
192    private static function get_test_summary() {
193        global $wpdb;
194
195        // An older WAF package doesn't define the class.
196        $test_rule = class_exists( Waf_Self_Check::class ) ? Waf_Self_Check::RULE_ID : -2;
197
198        // The table doesn't exist until the firewall is first turned on.
199        $suppress = $wpdb->suppress_errors();
200        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom table.
201        $row = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS total, SUM(rule_id = %d) AS tests, MAX(CASE WHEN rule_id = %d THEN timestamp END) AS last_test FROM {$wpdb->base_prefix}jetpack_waf_blocklog", $test_rule, $test_rule ) );
202        $wpdb->suppress_errors( $suppress );
203
204        $total    = $row ? (int) $row->total : 0;
205        $tests    = $row ? (int) $row->tests : 0;
206        $recorded = method_exists( Waf_Blocklog_Manager::class, 'has_blocked_requests' ) && Waf_Blocklog_Manager::has_blocked_requests();
207
208        // Silent mode logs requests without blocking them, so its log rows aren't blocks.
209        $is_silent = class_exists( Waf_Runner::class ) && 'silent' === get_option( Waf_Runner::MODE_OPTION_NAME );
210
211        return array(
212            'hasBlocked' => $recorded || ( ! $is_silent && $total > $tests ),
213            'lastTestAt' => $row && $row->last_test ? gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $row->last_test . ' UTC' ) ) : null,
214        );
215    }
216
217    /**
218     * When the automatic rules were last downloaded, as a Unix timestamp, or null while they're off or never were.
219     *
220     * @return int|null
221     */
222    public static function get_rules_updated_at() {
223        if ( ! class_exists( Waf_Stats::class ) || ! Waf_Rules_Manager::automatic_rules_enabled() ) {
224            return null;
225        }
226
227        $timestamp = (int) Waf_Stats::get_automatic_rules_last_updated();
228
229        return $timestamp > 0 ? $timestamp : null;
230    }
231
232    /**
233     * The visitor's IP address as the firewall sees it, to offer on the allow list and keep off the block list.
234     *
235     * Empty when it may be a proxy's: allowing that address would let every visitor behind the proxy past the rules.
236     *
237     * @return string
238     */
239    public static function get_current_ip() {
240        if ( ! class_exists( Waf_Request::class ) ) {
241            return '';
242        }
243
244        $ip = (string) ( new Waf_Request() )->get_real_user_ip_address();
245        if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
246            return '';
247        }
248
249        foreach ( self::FORWARDING_HEADERS as $header ) {
250            if ( empty( $_SERVER[ $header ] ) ) {
251                continue;
252            }
253
254            $forwarded = trim( explode( ',', wp_unslash( $_SERVER[ $header ] ) )[0] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated as an IP address below.
255            if ( ! filter_var( $forwarded, FILTER_VALIDATE_IP ) || inet_pton( $forwarded ) !== inet_pton( $ip ) ) {
256                return '';
257            }
258        }
259
260        return $ip;
261    }
262
263    /**
264     * Refuse a block list that covers the visitor's own address, which would lock them out of the site.
265     *
266     * A list is allowed when the address is unknown, so a wrong guess can't stop a legitimate save.
267     *
268     * @param string $list The block list being saved: addresses, ranges or CIDR blocks.
269     * @return true|WP_Error
270     */
271    public static function validate_ip_block_list( $list ) {
272        $ip = self::get_current_ip();
273        if ( '' === $ip ) {
274            return true;
275        }
276
277        if ( ! self::ip_list_covers( $list, $ip ) ) {
278            return true;
279        }
280
281        // The firewall checks the allow list first, so this address would never actually be blocked.
282        if (
283            class_exists( Waf_Rules_Manager::class )
284            && Waf_Rules_Manager::ip_allow_list_enabled()
285            && self::ip_list_covers( (string) get_option( Waf_Rules_Manager::IP_ALLOW_LIST_OPTION_NAME, '' ), $ip )
286        ) {
287            return new WP_Error(
288                'ip_block_list_includes_allowed_ip',
289                sprintf(
290                    /* translators: %s is the visitor's IP address. */
291                    __( 'Your IP address (%s) is on your allow list, so it can\'t be added to the block list. Remove it, or the range that includes it, from the block list and try again.', 'jetpack-protect-pkg' ),
292                    $ip
293                )
294            );
295        }
296
297        return new WP_Error(
298            'ip_block_list_includes_current_ip',
299            sprintf(
300                /* translators: %s is the visitor's IP address. */
301                __( 'Your IP address (%s) is on the block list, so saving it would block you from your own site. Remove it, or the range that includes it, and try again.', 'jetpack-protect-pkg' ),
302                $ip
303            )
304        );
305    }
306
307    /**
308     * Whether an IP list includes an address, through the address itself, a range or a CIDR block.
309     *
310     * @param string $list Addresses, ranges or CIDR blocks.
311     * @param string $ip   The address to look for.
312     * @return bool
313     */
314    private static function ip_list_covers( $list, $ip ) {
315        foreach ( IP_Utils::get_ip_addresses_from_string( $list ) as $entry ) {
316            // Split as the firewall does: a CIDR block, a hyphenated range or a single address.
317            $range    = strpos( $entry, '/' ) !== false ? array( $entry, null ) : explode( '-', $entry );
318            $is_match = count( $range ) === 2
319                ? IP_Utils::ip_address_is_in_range( $ip, $range[0], $range[1] )
320                : inet_pton( $entry ) === inet_pton( $ip );
321
322            if ( $is_match ) {
323                return true;
324            }
325        }
326
327        return false;
328    }
329
330    /**
331     * The firewall's IP lists, keyed like the Jetpack settings that hold them.
332     *
333     * @return array<string, string|bool>|object An empty object when the WAF package isn't loaded, so it's `{}` in JSON.
334     */
335    private static function get_ip_rules() {
336        if ( ! class_exists( Waf_Rules_Manager::class ) ) {
337            return (object) array();
338        }
339
340        return array(
341            Waf_Rules_Manager::IP_BLOCK_LIST_OPTION_NAME => (string) get_option( Waf_Rules_Manager::IP_BLOCK_LIST_OPTION_NAME, '' ),
342            Waf_Rules_Manager::IP_BLOCK_LIST_ENABLED_OPTION_NAME => (bool) Waf_Rules_Manager::ip_block_list_enabled(),
343            Waf_Rules_Manager::IP_ALLOW_LIST_OPTION_NAME => (string) get_option( Waf_Rules_Manager::IP_ALLOW_LIST_OPTION_NAME, '' ),
344            Waf_Rules_Manager::IP_ALLOW_LIST_ENABLED_OPTION_NAME => (bool) Waf_Rules_Manager::ip_allow_list_enabled(),
345        );
346    }
347
348    /**
349     * Returns the blocks unchanged, since the card no longer reads the request log.
350     *
351     * @deprecated $$next-version$$
352     *
353     * @param array $blocks  Blocks from the blocklog table, newest first.
354     * @param array $entries Decoded request log entries, oldest first.
355     * @return array The blocks, unchanged.
356     */
357    public static function add_request_details( array $blocks, array $entries ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Kept for callers.
358        _deprecated_function( __METHOD__, 'package-$$next-version$$' );
359        return $blocks;
360    }
361
362    /**
363     * The last 10 blocked requests, newest first.
364     *
365     * @return array
366     */
367    private static function get_recent_blocks() {
368        if ( ! method_exists( Waf_Blocklog_Manager::class, 'get_recent_blocks' ) ) {
369            return array();
370        }
371
372        return Waf_Blocklog_Manager::get_recent_blocks( 10 );
373    }
374}