Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.86% covered (danger)
46.86%
82 / 175
26.09% covered (danger)
26.09%
6 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Waf_Blocklog_Manager
46.86% covered (danger)
46.86%
82 / 175
26.09% covered (danger)
26.09%
6 / 23
719.77
0.00% covered (danger)
0.00%
0 / 1
 get_blocklog_file_path
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 connect_to_wordpress_db
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
10.15
 close_db_connection
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 serialize_option_value
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unserialize_option_value
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create_blocklog_table
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 write_blocklog_row
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
8.74
 get_daily_summary
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
15.55
 increment_daily_summary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 write_daily_summary_row
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 write_daily_summary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_all_time_block_count_value
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
10.75
 write_all_time_block_count_row
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 write_all_time_block_count
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 filter_last_30_days
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get_stored_option
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_current_day_block_count
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_thirty_days_block_counts
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_all_time_block_count
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 get_recent_blocks
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 get_default_all_time_stat_value
26.67% covered (danger)
26.67%
4 / 15
0.00% covered (danger)
0.00%
0 / 1
26.32
 get_request_headers
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 write_blocklog
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
12.08
1<?php
2/**
3 * Blocklog manager for the WAF
4 *
5 * @package automattic/jetpack-waf
6 */
7
8namespace Automattic\Jetpack\Waf;
9
10/**
11 * Class used to manage blocklog operations
12 */
13class Waf_Blocklog_Manager {
14
15    const BLOCKLOG_OPTION_NAME_DAILY_SUMMARY        = 'jetpack_waf_blocklog_daily_summary';
16    const BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT = 'jetpack_waf_all_time_block_count';
17
18    /**
19     * Database connection.
20     *
21     * @var \mysqli|null
22     */
23    private static $db_connection = null;
24
25    /**
26     * Gets the path to the waf-blocklog file.
27     *
28     * @return string The waf-blocklog file path.
29     */
30    public static function get_blocklog_file_path() {
31        return trailingslashit( JETPACK_WAF_DIR ) . 'waf-blocklog';
32    }
33
34    /**
35     * Connect to WordPress database.
36     *
37     * @return \mysqli|null
38     */
39    private static function connect_to_wordpress_db() {
40        if ( self::$db_connection !== null ) {
41            return self::$db_connection;
42        }
43
44        if ( ! file_exists( JETPACK_WAF_WPCONFIG ) ) {
45            return null;
46        }
47
48        require_once JETPACK_WAF_WPCONFIG;
49        // @phan-suppress-next-line PhanUndeclaredConstant - These constants are defined in the wp-config file.
50        $conn = new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli
51
52        if ( $conn->connect_error ) {
53            error_log( 'Could not connect to the database:' . $conn->connect_error );
54            return null;
55        }
56
57        self::$db_connection = $conn;
58        return self::$db_connection;
59    }
60
61    /**
62     * Close the database connection.
63     *
64     * @return void
65     */
66    private static function close_db_connection() {
67        if ( self::$db_connection ) {
68            self::$db_connection->close();
69            self::$db_connection = null;
70        }
71    }
72
73    /**
74     * Serialize a value for storage in a WordPress option.
75     *
76     * @param mixed $value The value to serialize.
77     * @return string The serialized value.
78     */
79    private static function serialize_option_value( $value ) {
80        return serialize( $value );
81    }
82
83    /**
84     * Unserialize a value from a WordPress option.
85     *
86     * @param string $value The serialized value.
87     * @return mixed The unserialized value.
88     */
89    private static function unserialize_option_value( string $value ) {
90        return unserialize( $value );
91    }
92
93    /**
94     * Create the log table when plugin is activated.
95     *
96     * @return void
97     */
98    public static function create_blocklog_table() {
99        global $wpdb;
100
101        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
102
103        $sql = "
104        CREATE TABLE {$wpdb->prefix}jetpack_waf_blocklog (
105            log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
106            timestamp datetime NOT NULL,
107            rule_id BIGINT NOT NULL,
108            reason longtext NOT NULL,
109            PRIMARY KEY (log_id),
110            KEY timestamp (timestamp)
111        )
112        ";
113
114        dbDelta( $sql );
115    }
116
117    /**
118     * Write block logs to database.
119     *
120     * @param array $log_data Log data.
121     *
122     * @return void
123     */
124    private static function write_blocklog_row( $log_data ) {
125        $conn = self::connect_to_wordpress_db();
126
127        if ( ! $conn ) {
128            return;
129        }
130
131        global $table_prefix;
132
133        $statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" );
134        if ( false !== $statement ) {
135            $statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] );
136            $statement->execute();
137
138            if ( $conn->insert_id > 100 ) {
139                $conn->query( "DELETE FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id LIMIT 1" );
140            }
141        }
142    }
143
144    /**
145     * Get the daily summary stats from the database.
146     *
147     * @return array The daily summary stats.
148     */
149    private static function get_daily_summary() {
150        global $table_prefix;
151        $db_connection = self::connect_to_wordpress_db();
152        if ( ! $db_connection ) {
153            return array();
154        }
155
156        $result = $db_connection->query( "SELECT option_value FROM {$table_prefix}options WHERE option_name = '" . self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY . "'" );
157        if ( ! $result ) {
158            return array();
159        }
160
161        $row = $result->fetch_assoc();
162        if ( ! $row ) {
163            return array();
164        }
165
166        $daily_summary = self::unserialize_option_value( $row['option_value'] );
167        $result->free();
168
169        return is_array( $daily_summary ) ? $daily_summary : array();
170    }
171
172    /**
173     * Increments the current date's daily summary stat.
174     *
175     * @param array $current_value The current value of the daily summary.
176     *
177     * @return array The updated daily summary.
178     */
179    public static function increment_daily_summary( array $current_value ) {
180        $date                   = gmdate( 'Y-m-d' );
181        $value                  = intval( $current_value[ $date ] ?? 0 );
182        $current_value[ $date ] = $value + 1;
183
184        return $current_value;
185    }
186
187    /**
188     * Update the daily summary option in the database.
189     *
190     * @param array $value The value to update.
191     *
192     * @return void
193     */
194    private static function write_daily_summary_row( array $value ) {
195        global $table_prefix;
196        $option_name = self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY;
197
198        $db_connection = self::connect_to_wordpress_db();
199        if ( ! $db_connection ) {
200            return;
201        }
202
203        $updated_value = self::serialize_option_value( $value );
204
205        $statement = $db_connection->prepare( "INSERT INTO {$table_prefix}options (option_name, option_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE option_value = ?" );
206        if ( false !== $statement ) {
207            $statement->bind_param( 'sss', $option_name, $updated_value, $updated_value );
208            $statement->execute();
209        }
210    }
211
212    /**
213     * Update the daily summary stats for the current date.
214     *
215     * @return void
216     */
217    private static function write_daily_summary() {
218        $stats = self::get_daily_summary();
219        $stats = self::increment_daily_summary( $stats );
220        $stats = self::filter_last_30_days( $stats );
221
222        self::write_daily_summary_row( $stats );
223    }
224
225    /**
226     * Get the all-time block count value from the database.
227     *
228     * @return int The all-time block count.
229     */
230    private static function get_all_time_block_count_value() {
231        global $table_prefix;
232        $db_connection = self::connect_to_wordpress_db();
233        if ( ! $db_connection ) {
234            return 0;
235        }
236
237        $result = $db_connection->query( "SELECT option_value FROM {$table_prefix}options WHERE option_name = '" . self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT . "'" );
238        if ( ! $result ) {
239            return 0;
240        }
241
242        $row = $result->fetch_assoc();
243        if ( ! $row ) {
244            return 0;
245        }
246
247        $all_time_block_count = intval( $row['option_value'] );
248        $result->free();
249
250        return $all_time_block_count;
251    }
252
253    /**
254     * Update the all-time block count value in the database.
255     *
256     * @param int $value The value to update.
257     * @return void
258     */
259    private static function write_all_time_block_count_row( int $value ) {
260        global $table_prefix;
261        $option_name = self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT;
262
263        $db_connection = self::connect_to_wordpress_db();
264        if ( ! $db_connection ) {
265            return;
266        }
267
268        $statement = $db_connection->prepare( "INSERT INTO {$table_prefix}options (option_name, option_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE option_value = ?" );
269        if ( false !== $statement ) {
270            $statement->bind_param( 'sii', $option_name, $value, $value );
271            $statement->execute();
272        }
273    }
274
275    /**
276     * Increment the all-time stats.
277     *
278     * @return void
279     */
280    private static function write_all_time_block_count() {
281        $block_count = self::get_all_time_block_count_value();
282        if ( ! $block_count ) {
283            $block_count = self::get_default_all_time_stat_value();
284        }
285
286        self::write_all_time_block_count_row( $block_count + 1 );
287    }
288
289    /**
290     * Filters the stats to retain only data for the last 30 days.
291     *
292     * @param array $stats The array of stats to prune.
293     *
294     * @return array Pruned stats array.
295     */
296    public static function filter_last_30_days( array $stats ) {
297        $today         = gmdate( 'Y-m-d' );
298        $one_month_ago = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
299
300        return array_filter(
301            $stats,
302            function ( $date ) use ( $one_month_ago, $today ) {
303                return $date >= $one_month_ago && $date <= $today;
304            },
305            ARRAY_FILTER_USE_KEY
306        );
307    }
308
309    /**
310     * Read an option the firewall writes with its own connection, which leaves the object cache stale.
311     *
312     * That connection uses the network's base prefix, so on a multisite subsite these aren't the site's own options.
313     *
314     * @param string $name    The option name.
315     * @param mixed  $default The value when the option doesn't exist.
316     * @return mixed
317     */
318    private static function get_stored_option( $name, $default ) {
319        global $wpdb;
320
321        if ( ! isset( $wpdb ) ) {
322            return get_option( $name, $default );
323        }
324
325        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The cache is what's stale.
326        $value = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->base_prefix}options WHERE option_name = %s", $name ) );
327
328        return null === $value ? $default : maybe_unserialize( $value );
329    }
330
331    /**
332     * Get the total number of blocked requests for today.
333     *
334     * @return int
335     */
336    public static function get_current_day_block_count() {
337        $stats = self::get_stored_option( self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY, array() );
338        $today = gmdate( 'Y-m-d' );
339
340        return $stats[ $today ] ?? 0;
341    }
342
343    /**
344     * Get the total number of blocked requests for last thirty days.
345     *
346     * @return int
347     */
348    public static function get_thirty_days_block_counts() {
349        $stats        = self::get_stored_option( self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY, array() );
350        $total_blocks = 0;
351
352        foreach ( $stats as $count ) {
353            $total_blocks += intval( $count );
354        }
355
356        return $total_blocks;
357    }
358
359    /**
360     * Get the total number of blocked requests for all time.
361     *
362     * @return int
363     */
364    public static function get_all_time_block_count() {
365        $all_time_block_count = self::get_stored_option( self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT, false );
366
367        if ( false !== $all_time_block_count ) {
368            return intval( $all_time_block_count );
369        }
370
371        return self::get_default_all_time_stat_value();
372    }
373
374    /**
375     * Get the most recent blocked requests, newest first. The table keeps only the last 100.
376     *
377     * @since $$next-version$$
378     *
379     * @param int $limit How many to return.
380     * @return array<int, array{id: int, timestamp: string, ruleId: int, reason: string}> Timestamps are ISO 8601 UTC.
381     */
382    public static function get_recent_blocks( $limit = 10 ) {
383        global $wpdb;
384
385        $suppress = $wpdb->suppress_errors();
386        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom table, read on demand.
387        $rows = $wpdb->get_results(
388            $wpdb->prepare(
389                "SELECT log_id, timestamp, rule_id, reason FROM {$wpdb->base_prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT %d",
390                $limit
391            ),
392            ARRAY_A
393        );
394        $wpdb->suppress_errors( $suppress );
395
396        if ( ! is_array( $rows ) ) {
397            return array();
398        }
399
400        return array_map(
401            function ( $row ) {
402                return array(
403                    'id'        => (int) $row['log_id'],
404                    'timestamp' => str_replace( ' ', 'T', $row['timestamp'] ) . 'Z',
405                    'ruleId'    => (int) $row['rule_id'],
406                    'reason'    => (string) $row['reason'],
407                );
408            },
409            $rows
410        );
411    }
412
413    /**
414     * Compute the initial all-time stats value.
415     *
416     * @return int The initial all-time stats value.
417     */
418    private static function get_default_all_time_stat_value() {
419        global $wpdb;
420
421        // Inside WordPress, so connecting would load wp-config.php a second time.
422        if ( isset( $wpdb ) ) {
423            // The table doesn't exist until the firewall is first turned on.
424            $suppress = $wpdb->suppress_errors();
425            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom table.
426            $last_id = $wpdb->get_var( "SELECT log_id FROM {$wpdb->base_prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT 1" );
427            $wpdb->suppress_errors( $suppress );
428
429            return intval( $last_id );
430        }
431
432        $conn = self::connect_to_wordpress_db();
433        if ( ! $conn ) {
434            return 0;
435        }
436
437        global $table_prefix;
438
439        $last_log_id_result = $conn->query( "SELECT log_id FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT 1" );
440
441        $all_time_block_count = 0;
442
443        if ( $last_log_id_result && $last_log_id_result->num_rows > 0 ) {
444            $row = $last_log_id_result->fetch_assoc();
445            if ( $row !== null && isset( $row['log_id'] ) ) {
446                $all_time_block_count = $row['log_id'];
447            }
448        }
449
450        return intval( $all_time_block_count );
451    }
452
453    /**
454     * Get the headers for logging purposes.
455     *
456     * @return array The headers.
457     */
458    public static function get_request_headers() {
459        $all_headers     = getallheaders();
460        $exclude_headers = array( 'Authorization', 'Cookie', 'Proxy-Authorization', 'Set-Cookie' );
461
462        foreach ( $exclude_headers as $header ) {
463            unset( $all_headers[ $header ] );
464        }
465
466        return $all_headers;
467    }
468
469    /**
470     * Write block logs. We won't write to the file if it exceeds 100 mb.
471     *
472     * @param string $rule_id The rule ID that triggered the block.
473     * @param string $reason  The reason for the block.
474     *
475     * @return void
476     */
477    public static function write_blocklog( $rule_id, $reason ) {
478        $log_data                 = array();
479        $log_data['rule_id']      = $rule_id;
480        $log_data['reason']       = $reason;
481        $log_data['timestamp']    = gmdate( 'Y-m-d H:i:s' );
482        $log_data['request_uri']  = isset( $_SERVER['REQUEST_URI'] ) ? \stripslashes( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
483        $log_data['user_agent']   = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \stripslashes( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
484        $log_data['referer']      = isset( $_SERVER['HTTP_REFERER'] ) ? \stripslashes( $_SERVER['HTTP_REFERER'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
485        $log_data['content_type'] = isset( $_SERVER['CONTENT_TYPE'] ) ? \stripslashes( $_SERVER['CONTENT_TYPE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
486        $log_data['get_params']   = json_encode( $_GET, JSON_UNESCAPED_SLASHES );
487
488        if ( defined( 'JETPACK_WAF_SHARE_DEBUG_DATA' ) && JETPACK_WAF_SHARE_DEBUG_DATA ) {
489            $log_data['post_params'] = json_encode( $_POST, JSON_UNESCAPED_SLASHES );
490            $log_data['headers']     = self::get_request_headers();
491        }
492
493        if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) {
494            $file_path   = JETPACK_WAF_DIR . '/waf-blocklog';
495            $file_exists = file_exists( $file_path );
496
497            if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) {
498                $fp = fopen( $file_path, 'a+' );
499
500                if ( $fp ) {
501                    try {
502                        fwrite( $fp, json_encode( $log_data, JSON_UNESCAPED_SLASHES ) . "\n" );
503                    } finally {
504                        fclose( $fp );
505                    }
506                }
507            }
508        }
509
510        self::write_daily_summary();
511        self::write_all_time_block_count();
512        self::write_blocklog_row( $log_data );
513        self::close_db_connection();
514    }
515}