Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.89% covered (danger)
14.89%
14 / 94
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Firewall
15.22% covered (danger)
15.22%
14 / 92
10.00% covered (danger)
10.00%
1 / 10
738.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
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_current_ip
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 shares_data
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 get_recent_blocks
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 add_request_details
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
11
 read_request_log
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
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_Runner;
16use Automattic\Jetpack\Waf\Waf_Self_Check;
17use WP_Error;
18use WP_REST_Server;
19
20if ( ! defined( 'ABSPATH' ) ) {
21    exit( 0 );
22}
23
24/**
25 * The firewall's on/off state, blocked request counts and a request it always blocks, to test it.
26 *
27 * @since $$next-version$$
28 */
29class Firewall implements Dashboard_Section {
30
31    /**
32     * The key the section's state is printed under.
33     *
34     * @return string
35     */
36    public function get_key() {
37        return 'firewall';
38    }
39
40    /**
41     * The firewall module's state, its blocked requests and whether a Scan plan is active.
42     *
43     * @return array
44     */
45    public function get_state() {
46        // The block counts persist while the firewall is off, so they are sent either way.
47        return array_merge(
48            Dashboard::get_module_state( 'waf' ),
49            self::get_blocks(),
50            array(
51                'hasScan'    => Dashboard::has_scan_plan(),
52                'currentIp'  => self::get_current_ip(),
53                'sharesData' => self::shares_data(),
54            )
55        );
56    }
57
58    /**
59     * Register `jetpack/v4/protect-dashboard/firewall/test` and `…/firewall/blocks`.
60     *
61     * The firewall's settings are saved through existing endpoints.
62     *
63     * @return void
64     */
65    public function register_routes() {
66        register_rest_route(
67            'jetpack/v4',
68            '/protect-dashboard/firewall/test',
69            array(
70                'methods'             => WP_REST_Server::CREATABLE,
71                'callback'            => array( __CLASS__, 'start_test' ),
72                'permission_callback' => array( Dashboard::class, 'can_manage' ),
73            )
74        );
75
76        register_rest_route(
77            'jetpack/v4',
78            '/protect-dashboard/firewall/blocks',
79            array(
80                'methods'             => WP_REST_Server::READABLE,
81                'callback'            => array( __CLASS__, 'get_blocks' ),
82                'permission_callback' => array( Dashboard::class, 'can_manage' ),
83            )
84        );
85    }
86
87    /**
88     * A one-time URL the firewall blocks, for the browser to request.
89     *
90     * @return array|WP_Error
91     */
92    public static function start_test() {
93        // The WAF package bundled by another plugin can predate the self-check.
94        if ( ! class_exists( Waf_Self_Check::class ) ) {
95            return new WP_Error(
96                'firewall_test_unavailable',
97                __( 'Update Jetpack to test the firewall.', 'jetpack-protect-pkg' ),
98                array( 'status' => 501 )
99            );
100        }
101
102        $token = Waf_Self_Check::create_token();
103        if ( ! $token ) {
104            return new WP_Error(
105                'firewall_test_failed',
106                __( 'The firewall test couldn’t start.', 'jetpack-protect-pkg' ),
107                array( 'status' => 500 )
108            );
109        }
110
111        return array( 'url' => add_query_arg( Waf_Self_Check::QUERY_PARAM, $token, home_url( '/' ) ) );
112    }
113
114    /**
115     * The recent blocks and the all-time count, refreshed after a test.
116     *
117     * @return array
118     */
119    public static function get_blocks() {
120        return array(
121            'recentBlocks' => self::get_recent_blocks(),
122            'blockedCount' => class_exists( Waf_Blocklog_Manager::class )
123                ? Waf_Blocklog_Manager::get_all_time_block_count()
124                : null,
125        );
126    }
127
128    /**
129     * The visitor's IP address as the firewall sees it, for "Add my IP address" on the always-allowed list.
130     *
131     * Behind a proxy, `X-Forwarded-For` can name the proxy for every visitor; allowing it would exempt all traffic.
132     *
133     * @return string Empty when the WAF package isn't loaded.
134     */
135    public static function get_current_ip() {
136        if ( ! class_exists( Waf_Request::class ) ) {
137            return '';
138        }
139
140        return (string) ( new Waf_Request() )->get_real_user_ip_address();
141    }
142
143    /**
144     * Whether the firewall keeps its request log, the "Share basic data" setting.
145     *
146     * @return bool
147     */
148    public static function shares_data() {
149        return class_exists( Waf_Runner::class ) && (bool) get_option( Waf_Runner::SHARE_DATA_OPTION_NAME );
150    }
151
152    /**
153     * The last 10 blocked requests, newest first, with their request details when the request log has them.
154     *
155     * @return array
156     */
157    public static function get_recent_blocks() {
158        if ( ! method_exists( Waf_Blocklog_Manager::class, 'get_recent_blocks' ) ) {
159            return array();
160        }
161
162        $blocks = Waf_Blocklog_Manager::get_recent_blocks( 10 );
163
164        return self::shares_data() ? self::add_request_details( $blocks, self::read_request_log() ) : $blocks;
165    }
166
167    /**
168     * Match each block to its entry in the request log, by second and rule.
169     *
170     * @param array $blocks  Blocks from the blocklog table, newest first.
171     * @param array $entries Decoded request log entries, oldest first.
172     * @return array The blocks, with `uri` and `userAgent` where an entry matched.
173     */
174    public static function add_request_details( array $blocks, array $entries ) {
175        $by_key = array();
176        foreach ( $entries as $entry ) {
177            if ( isset( $entry['rule_id'] ) && is_string( $entry['timestamp'] ?? null ) ) {
178                $by_key[ str_replace( ' ', 'T', $entry['timestamp'] ) . 'Z|' . (int) $entry['rule_id'] ][] = $entry;
179            }
180        }
181
182        foreach ( $blocks as &$block ) {
183            $key = $block['timestamp'] . '|' . $block['ruleId'];
184            // Several blocks in one second: the newest block takes the newest entry.
185            $entry = empty( $by_key[ $key ] ) ? null : array_pop( $by_key[ $key ] );
186            if ( ! $entry ) {
187                continue;
188            }
189            if ( ! empty( $entry['request_uri'] ) && is_string( $entry['request_uri'] ) ) {
190                $block['uri'] = substr( $entry['request_uri'], 0, 2048 );
191            }
192            if ( ! empty( $entry['user_agent'] ) && is_string( $entry['user_agent'] ) ) {
193                $block['userAgent'] = substr( $entry['user_agent'], 0, 512 );
194            }
195        }
196
197        return $blocks;
198    }
199
200    /**
201     * The newest entries of the firewall's request log, oldest first.
202     *
203     * @return array
204     */
205    private static function read_request_log() {
206        if ( ! class_exists( Waf_Constants::class ) ) {
207            return array();
208        }
209        Waf_Constants::define_waf_directory();
210
211        $path = JETPACK_WAF_DIR . '/waf-blocklog';
212        $size = is_readable( $path ) ? filesize( $path ) : 0;
213        if ( ! $size ) {
214            return array();
215        }
216
217        // The log can grow to 100 MB; its tail holds the last 10 blocks.
218        $tail_size = 256 * 1024;
219        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Read-only, from an offset.
220        $handle = fopen( $path, 'rb' );
221        if ( ! $handle ) {
222            return array();
223        }
224        fseek( $handle, max( 0, $size - $tail_size ) );
225        $tail = (string) fread( $handle, $tail_size ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
226        fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
227
228        $lines = explode( "\n", $tail );
229        if ( $size > $tail_size ) {
230            array_shift( $lines ); // Starts mid-entry.
231        }
232
233        $entries = array();
234        foreach ( $lines as $line ) {
235            $entry = json_decode( $line, true );
236            if ( is_array( $entry ) ) {
237                $entries[] = $entry;
238            }
239        }
240
241        return $entries;
242    }
243}