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