Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.76% covered (warning)
52.76%
67 / 127
38.46% covered (danger)
38.46%
5 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Firewall
53.60% covered (warning)
53.60%
67 / 125
38.46% covered (danger)
38.46%
5 / 13
333.61
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 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 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
 get_ip_rules
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 shares_data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_recent_blocks
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 add_request_details
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 truncate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 read_request_log
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
10.64
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\Protect\Dashboard;
11use Automattic\Jetpack\Protect\Dashboard_Section;
12use Automattic\Jetpack\Waf\Waf_Blocklog_Manager;
13use Automattic\Jetpack\Waf\Waf_Constants;
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                'currentIp'      => self::get_current_ip(),
66                'ipRules'        => self::get_ip_rules(),
67                'rulesUpdatedAt' => self::get_rules_updated_at(),
68            )
69        );
70    }
71
72    /**
73     * Register `jetpack/v4/protect-dashboard/firewall/test` and `…/firewall/blocks`.
74     *
75     * The firewall's settings are saved through existing endpoints.
76     *
77     * @return void
78     */
79    public function register_routes() {
80        register_rest_route(
81            'jetpack/v4',
82            '/protect-dashboard/firewall/test',
83            array(
84                'methods'             => WP_REST_Server::CREATABLE,
85                'callback'            => array( __CLASS__, 'start_test' ),
86                'permission_callback' => array( Dashboard::class, 'can_manage' ),
87            )
88        );
89
90        register_rest_route(
91            'jetpack/v4',
92            '/protect-dashboard/firewall/blocks',
93            array(
94                'methods'             => WP_REST_Server::READABLE,
95                'callback'            => array( __CLASS__, 'get_blocks' ),
96                'permission_callback' => array( Dashboard::class, 'can_manage' ),
97            )
98        );
99    }
100
101    /**
102     * A one-time URL the firewall blocks, for the browser to request.
103     *
104     * @return array|WP_Error
105     */
106    public static function start_test() {
107        // The WAF package bundled by another plugin can predate the self-check.
108        if ( ! class_exists( Waf_Self_Check::class ) ) {
109            return new WP_Error(
110                'firewall_test_unavailable',
111                __( 'Update Jetpack to test the firewall.', 'jetpack-protect-pkg' ),
112                array( 'status' => 501 )
113            );
114        }
115
116        $token = Waf_Self_Check::create_token();
117        if ( ! $token ) {
118            return new WP_Error(
119                'firewall_test_failed',
120                __( 'The firewall test couldn’t start.', 'jetpack-protect-pkg' ),
121                array( 'status' => 500 )
122            );
123        }
124
125        return array( 'url' => add_query_arg( Waf_Self_Check::QUERY_PARAM, $token, home_url( '/' ) ) );
126    }
127
128    /**
129     * The recent blocks and the block counts, refreshed after a test.
130     *
131     * Today and the last 30 days are UTC days, as the firewall records them.
132     *
133     * @return array
134     */
135    public static function get_blocks() {
136        $counts = class_exists( Waf_Stats::class ) ? Waf_Stats::get_blocked_requests() : array();
137
138        return array(
139            'recentBlocks'      => self::get_recent_blocks(),
140            'blockedCount'      => isset( $counts['all_time'] ) ? (int) $counts['all_time'] : null,
141            'blockedToday'      => isset( $counts['current_day'] ) ? (int) $counts['current_day'] : null,
142            'blockedLast30Days' => isset( $counts['thirty_days'] ) ? (int) $counts['thirty_days'] : null,
143        );
144    }
145
146    /**
147     * When the automatic rules were last downloaded, as a Unix timestamp, or null while they're off or never were.
148     *
149     * @return int|null
150     */
151    public static function get_rules_updated_at() {
152        if ( ! class_exists( Waf_Stats::class ) || ! Waf_Rules_Manager::automatic_rules_enabled() ) {
153            return null;
154        }
155
156        $timestamp = (int) Waf_Stats::get_automatic_rules_last_updated();
157
158        return $timestamp > 0 ? $timestamp : null;
159    }
160
161    /**
162     * The visitor's IP address as the firewall sees it, for "Add it to the list" on the always-allowed list.
163     *
164     * Empty when it may be a proxy's: allowing that address would let every visitor behind the proxy past the rules.
165     *
166     * @return string
167     */
168    public static function get_current_ip() {
169        if ( ! class_exists( Waf_Request::class ) ) {
170            return '';
171        }
172
173        $ip = (string) ( new Waf_Request() )->get_real_user_ip_address();
174        if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
175            return '';
176        }
177
178        foreach ( self::FORWARDING_HEADERS as $header ) {
179            if ( empty( $_SERVER[ $header ] ) ) {
180                continue;
181            }
182
183            $forwarded = trim( explode( ',', wp_unslash( $_SERVER[ $header ] ) )[0] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated as an IP address below.
184            if ( ! filter_var( $forwarded, FILTER_VALIDATE_IP ) || inet_pton( $forwarded ) !== inet_pton( $ip ) ) {
185                return '';
186            }
187        }
188
189        return $ip;
190    }
191
192    /**
193     * The firewall's IP lists, keyed like the Jetpack settings that hold them.
194     *
195     * @return array<string, string|bool>|object An empty object when the WAF package isn't loaded, so it's `{}` in JSON.
196     */
197    private static function get_ip_rules() {
198        if ( ! class_exists( Waf_Rules_Manager::class ) ) {
199            return (object) array();
200        }
201
202        return array(
203            Waf_Rules_Manager::IP_BLOCK_LIST_OPTION_NAME => (string) get_option( Waf_Rules_Manager::IP_BLOCK_LIST_OPTION_NAME, '' ),
204            Waf_Rules_Manager::IP_BLOCK_LIST_ENABLED_OPTION_NAME => (bool) Waf_Rules_Manager::ip_block_list_enabled(),
205            Waf_Rules_Manager::IP_ALLOW_LIST_OPTION_NAME => (string) get_option( Waf_Rules_Manager::IP_ALLOW_LIST_OPTION_NAME, '' ),
206            Waf_Rules_Manager::IP_ALLOW_LIST_ENABLED_OPTION_NAME => (bool) Waf_Rules_Manager::ip_allow_list_enabled(),
207        );
208    }
209
210    /**
211     * Whether the firewall keeps its request log, the "Share basic data" setting.
212     *
213     * @return bool
214     */
215    private static function shares_data() {
216        return class_exists( Waf_Runner::class ) && (bool) get_option( Waf_Runner::SHARE_DATA_OPTION_NAME );
217    }
218
219    /**
220     * The last 10 blocked requests, newest first, with their request details when the request log has them.
221     *
222     * @return array
223     */
224    private static function get_recent_blocks() {
225        if ( ! method_exists( Waf_Blocklog_Manager::class, 'get_recent_blocks' ) ) {
226            return array();
227        }
228
229        $blocks = Waf_Blocklog_Manager::get_recent_blocks( 10 );
230
231        if ( ! $blocks || ! self::shares_data() ) {
232            return $blocks;
233        }
234
235        $oldest = end( $blocks )['timestamp'];
236
237        return self::add_request_details( $blocks, self::read_request_log( $oldest ) );
238    }
239
240    /**
241     * Match each block to its entry in the request log, by second and rule.
242     *
243     * @param array $blocks  Blocks from the blocklog table, newest first.
244     * @param array $entries Decoded request log entries, oldest first.
245     * @return array The blocks, with `uri` and `userAgent` where an entry matched.
246     */
247    public static function add_request_details( array $blocks, array $entries ) {
248        $by_key = array();
249        foreach ( $entries as $entry ) {
250            if ( isset( $entry['rule_id'] ) && is_string( $entry['timestamp'] ?? null ) ) {
251                $by_key[ str_replace( ' ', 'T', $entry['timestamp'] ) . 'Z|' . (int) $entry['rule_id'] ][] = $entry;
252            }
253        }
254
255        foreach ( $blocks as &$block ) {
256            $key = $block['timestamp'] . '|' . $block['ruleId'];
257            // Several blocks in one second: the newest block takes the newest entry.
258            $entry = empty( $by_key[ $key ] ) ? null : array_pop( $by_key[ $key ] );
259            if ( ! $entry ) {
260                continue;
261            }
262            if ( ! empty( $entry['request_uri'] ) && is_string( $entry['request_uri'] ) ) {
263                $block['uri'] = self::truncate( $entry['request_uri'], 2048 );
264            }
265            if ( ! empty( $entry['user_agent'] ) && is_string( $entry['user_agent'] ) ) {
266                $block['userAgent'] = self::truncate( $entry['user_agent'], 512 );
267            }
268        }
269        unset( $block );
270
271        return $blocks;
272    }
273
274    /**
275     * A string cut to a byte length, without splitting a multibyte character.
276     *
277     * @param string $value  The string.
278     * @param int    $length The most bytes to keep.
279     * @return string
280     */
281    private static function truncate( $value, $length ) {
282        return function_exists( 'mb_strcut' ) ? mb_strcut( $value, 0, $length, 'UTF-8' ) : substr( $value, 0, $length );
283    }
284
285    /**
286     * The firewall's request log entries since a block, oldest first.
287     *
288     * @param string $since The oldest block's timestamp, as `get_recent_blocks()` formats it.
289     * @return array
290     */
291    private static function read_request_log( $since ) {
292        if ( ! class_exists( Waf_Constants::class ) ) {
293            return array();
294        }
295        Waf_Constants::define_waf_directory();
296
297        $path = Waf_Blocklog_Manager::get_blocklog_file_path();
298        $size = is_readable( $path ) ? filesize( $path ) : 0;
299        if ( ! $size ) {
300            return array();
301        }
302
303        // The log can grow to 100 MB; its tail holds the last 10 blocks.
304        $tail_size = 256 * 1024;
305        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Read-only, from an offset.
306        $handle = fopen( $path, 'rb' );
307        if ( ! $handle ) {
308            return array();
309        }
310        fseek( $handle, max( 0, $size - $tail_size ) );
311        $tail = (string) fread( $handle, $tail_size ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
312        fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
313
314        $lines = explode( "\n", $tail );
315        if ( $size > $tail_size ) {
316            array_shift( $lines ); // Starts mid-entry.
317        }
318
319        // The log's timestamps are `Y-m-d H:i:s`, which sort as text.
320        $since   = str_replace( array( 'T', 'Z' ), array( ' ', '' ), $since );
321        $entries = array();
322        foreach ( array_reverse( $lines ) as $line ) {
323            $entry = json_decode( $line, true );
324            if ( ! is_array( $entry ) ) {
325                continue;
326            }
327            if ( is_string( $entry['timestamp'] ?? null ) && $entry['timestamp'] < $since ) {
328                break;
329            }
330            $entries[] = $entry;
331        }
332
333        return array_reverse( $entries );
334    }
335}