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