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