Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.29% covered (warning)
50.29%
88 / 175
34.78% covered (danger)
34.78%
8 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Waf_Blocklog_Manager
50.29% covered (warning)
50.29%
88 / 175
34.78% covered (danger)
34.78%
8 / 23
601.22
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
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 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
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 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        // Old days are only pruned when a block is written, so a quiet site still has them.
350        $stats        = self::filter_last_30_days( (array) self::get_stored_option( self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY, array() ) );
351        $total_blocks = 0;
352
353        foreach ( $stats as $count ) {
354            $total_blocks += intval( $count );
355        }
356
357        return $total_blocks;
358    }
359
360    /**
361     * Get the total number of blocked requests for all time.
362     *
363     * @return int
364     */
365    public static function get_all_time_block_count() {
366        $all_time_block_count = self::get_stored_option( self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT, false );
367
368        if ( false !== $all_time_block_count ) {
369            return intval( $all_time_block_count );
370        }
371
372        return self::get_default_all_time_stat_value();
373    }
374
375    /**
376     * Get the most recent blocked requests, newest first. The table keeps only the last 100.
377     *
378     * @since 0.30.0
379     *
380     * @param int $limit How many to return.
381     * @return array<int, array{id: int, timestamp: string, ruleId: int, reason: string}> Timestamps are ISO 8601 UTC.
382     */
383    public static function get_recent_blocks( $limit = 10 ) {
384        global $wpdb;
385
386        $suppress = $wpdb->suppress_errors();
387        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom table, read on demand.
388        $rows = $wpdb->get_results(
389            $wpdb->prepare(
390                "SELECT log_id, timestamp, rule_id, reason FROM {$wpdb->base_prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT %d",
391                $limit
392            ),
393            ARRAY_A
394        );
395        $wpdb->suppress_errors( $suppress );
396
397        if ( ! is_array( $rows ) ) {
398            return array();
399        }
400
401        return array_map(
402            function ( $row ) {
403                return array(
404                    'id'        => (int) $row['log_id'],
405                    'timestamp' => str_replace( ' ', 'T', $row['timestamp'] ) . 'Z',
406                    'ruleId'    => (int) $row['rule_id'],
407                    'reason'    => (string) $row['reason'],
408                );
409            },
410            $rows
411        );
412    }
413
414    /**
415     * Compute the initial all-time stats value.
416     *
417     * @return int The initial all-time stats value.
418     */
419    private static function get_default_all_time_stat_value() {
420        global $wpdb;
421
422        // Inside WordPress, so connecting would load wp-config.php a second time.
423        if ( isset( $wpdb ) ) {
424            // The table doesn't exist until the firewall is first turned on.
425            $suppress = $wpdb->suppress_errors();
426            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom table.
427            $last_id = $wpdb->get_var( "SELECT log_id FROM {$wpdb->base_prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT 1" );
428            $wpdb->suppress_errors( $suppress );
429
430            return intval( $last_id );
431        }
432
433        $conn = self::connect_to_wordpress_db();
434        if ( ! $conn ) {
435            return 0;
436        }
437
438        global $table_prefix;
439
440        $last_log_id_result = $conn->query( "SELECT log_id FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT 1" );
441
442        $all_time_block_count = 0;
443
444        if ( $last_log_id_result && $last_log_id_result->num_rows > 0 ) {
445            $row = $last_log_id_result->fetch_assoc();
446            if ( $row !== null && isset( $row['log_id'] ) ) {
447                $all_time_block_count = $row['log_id'];
448            }
449        }
450
451        return intval( $all_time_block_count );
452    }
453
454    /**
455     * Get the headers for logging purposes.
456     *
457     * @return array The headers.
458     */
459    public static function get_request_headers() {
460        $all_headers     = getallheaders();
461        $exclude_headers = array( 'Authorization', 'Cookie', 'Proxy-Authorization', 'Set-Cookie' );
462
463        foreach ( $exclude_headers as $header ) {
464            unset( $all_headers[ $header ] );
465        }
466
467        return $all_headers;
468    }
469
470    /**
471     * Write block logs. We won't write to the file if it exceeds 100 mb.
472     *
473     * @param string $rule_id The rule ID that triggered the block.
474     * @param string $reason  The reason for the block.
475     *
476     * @return void
477     */
478    public static function write_blocklog( $rule_id, $reason ) {
479        $log_data                 = array();
480        $log_data['rule_id']      = $rule_id;
481        $log_data['reason']       = $reason;
482        $log_data['timestamp']    = gmdate( 'Y-m-d H:i:s' );
483        $log_data['request_uri']  = isset( $_SERVER['REQUEST_URI'] ) ? \stripslashes( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
484        $log_data['user_agent']   = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \stripslashes( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
485        $log_data['referer']      = isset( $_SERVER['HTTP_REFERER'] ) ? \stripslashes( $_SERVER['HTTP_REFERER'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
486        $log_data['content_type'] = isset( $_SERVER['CONTENT_TYPE'] ) ? \stripslashes( $_SERVER['CONTENT_TYPE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
487        $log_data['get_params']   = json_encode( $_GET, JSON_UNESCAPED_SLASHES );
488
489        if ( defined( 'JETPACK_WAF_SHARE_DEBUG_DATA' ) && JETPACK_WAF_SHARE_DEBUG_DATA ) {
490            $log_data['post_params'] = json_encode( $_POST, JSON_UNESCAPED_SLASHES );
491            $log_data['headers']     = self::get_request_headers();
492        }
493
494        if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) {
495            $file_path   = JETPACK_WAF_DIR . '/waf-blocklog';
496            $file_exists = file_exists( $file_path );
497
498            if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) {
499                $fp = fopen( $file_path, 'a+' );
500
501                if ( $fp ) {
502                    try {
503                        fwrite( $fp, json_encode( $log_data, JSON_UNESCAPED_SLASHES ) . "\n" );
504                    } finally {
505                        fclose( $fp );
506                    }
507                }
508            }
509        }
510
511        self::write_daily_summary();
512        self::write_all_time_block_count();
513        self::write_blocklog_row( $log_data );
514        self::close_db_connection();
515    }
516}