hide_errors(); } $ctx = self::context(); if ( '' === self::skip_reason( $ctx ) ) { CSDT_Storage::record_traffic( self::build_row( $ctx ) ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } finally { global $wpdb; if ( null !== $wpdb_errors && is_object( $wpdb ) && method_exists( $wpdb, 'show_errors' ) ) { $wpdb->show_errors( (bool) $wpdb_errors ); } } } /** * Everything the skip rules and the row need, read from the environment once. * * Separate from skip_reason() so the rules can be tested without redefining constants, which * PHP cannot do within one process. * * @return array */ public static function context(): array { $ajax_action = null; if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { $ajax_action = (string) CSDT_Request::routing_key( 'action', 'request' ); } $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; $is_rest = defined( 'REST_REQUEST' ) && REST_REQUEST; $status = http_response_code(); // The user id only when a login cookie is present at all. Validating a cookie can cost a // query, and anonymous traffic, nearly all of it, has no cookie to validate. $user_id = 0; if ( CSDT_Request::has_login_cookie() && function_exists( 'get_current_user_id' ) ) { $user_id = (int) get_current_user_id(); } // Every read below is on `shutdown`, after wp_magic_quotes(), so the values are unslashed. return array( 'cli' => defined( 'WP_CLI' ) && WP_CLI, 'cron' => ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ), 'ajax_action' => $ajax_action, 'rest' => $is_rest, 'own' => $is_rest ? self::is_own_address( $ip ) : false, // The site's own signed loopback (cron spawn, Upgrade Verification). Header first, so // a visitor without it costs one isset. 'self' => class_exists( 'CSDT_Auto_Block' ) && CSDT_Auto_Block::is_signed_self_request(), 'ip' => $ip, 'method' => CSDT_Request::server( 'REQUEST_METHOD', 16 ), 'path' => CSDT_Storage::truncate_path( CSDT_Request::server( 'REQUEST_URI', 8192 ) ), 'status' => false === $status ? 200 : (int) $status, 'user_agent' => CSDT_Request::server( 'HTTP_USER_AGENT', 1024 ), 'country' => CSDT_Storage::country_from_header(), 'user_id' => $user_id, ); } /** * Why this request is not recorded, or '' when it is. * * Returns a reason rather than a bool so a test can tell WHICH rule fired: a skip that * happens for the wrong reason is a rule that will stop working the moment the right one * changes. * * @param array $ctx From context(). * @return string */ public static function skip_reason( array $ctx ): string { if ( ! empty( $ctx['cli'] ) ) { return 'cli'; } if ( ! empty( $ctx['cron'] ) ) { return 'cron'; } $action = isset( $ctx['ajax_action'] ) ? (string) $ctx['ajax_action'] : ''; if ( 'heartbeat' === $action ) { return 'heartbeat'; } if ( self::POLL_ACTION === $action ) { return 'own-poll'; } if ( ! empty( $ctx['rest'] ) && ! empty( $ctx['own'] ) ) { return 'self-rest'; } if ( ! empty( $ctx['self'] ) ) { return 'self-signed'; } if ( 1 === preg_match( self::ASSET_PATTERN, (string) ( $ctx['path'] ?? '' ) ) ) { return 'asset'; } if ( '' === (string) ( $ctx['ip'] ?? '' ) ) { return 'no-address'; } return ''; } /** * The row to store, from a context. * * @param array $ctx From context(). * @return array */ public static function build_row( array $ctx ): array { $ua = (string) ( $ctx['user_agent'] ?? '' ); return array( 'ip' => (string) ( $ctx['ip'] ?? '' ), 'country' => (string) ( $ctx['country'] ?? '' ), 'method' => (string) ( $ctx['method'] ?? '' ), 'path' => CSDT_Storage::truncate_path( (string) ( $ctx['path'] ?? '' ) ), 'status' => (int) ( $ctx['status'] ?? 0 ), 'user_agent' => (string) substr( $ua, 0, CSDT_Storage::TRAFFIC_UA_MAX ), 'user_id' => (int) ( $ctx['user_id'] ?? 0 ), 'kind' => self::classify( (int) ( $ctx['status'] ?? 0 ), $ua ), ); } /** * human, crawler or blocked. * * Blocked first: a refused request is the most important thing in the feed whoever sent it, * and 403 and 429 are the two statuses this plugin's blockers answer with (the blocklist, * country rules and firewall refuse with 403, the rate limiter with 429). An empty user agent * is a crawler, because every browser sends one. * * @param int $status HTTP status. * @param string $ua User agent. * @return string */ public static function classify( int $status, string $ua ): string { if ( 403 === $status || 429 === $status ) { return 'blocked'; } if ( '' === trim( $ua ) || 1 === preg_match( self::CRAWLER_PATTERN, $ua ) ) { return 'crawler'; } return 'human'; } /** * Whether an address is this server's own: unresolvable, private, loopback, or one of the * site's known public egress addresses. * * @param string $ip Client address. * @return bool */ private static function is_own_address( string $ip ): bool { if ( '' === $ip ) { return true; } if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return true; } return class_exists( 'CSDT_Login' ) && is_callable( array( 'CSDT_Login', 'own_public_ips' ) ) && in_array( $ip, (array) CSDT_Login::own_public_ips(), true ); } // ── AJAX ───────────────────────────────────────────────────────────── /** * Rows newer than the last one the panel has, newest first. * * Read-only, but it hands over addresses and user agents, so it carries the same nonce and * capability as the panel's save. * * @return void */ public static function ajax_poll(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $after = isset( $_POST['after'] ) ? absint( wp_unslash( $_POST['after'] ) ) : 0; $filter = isset( $_POST['filter'] ) ? sanitize_key( wp_unslash( $_POST['filter'] ) ) : 'all'; $rows = CSDT_Storage::recent_traffic( $after, $filter, self::POLL_LIMIT ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); return; } if ( null === $rows ) { // Not an empty list: "the table cannot be read" and "nobody visited" must not look alike. wp_send_json_error( __( 'The live traffic table cannot be read.', 'cloudscale-cyber-devtools' ) ); return; } $out = array(); foreach ( $rows as $r ) { $out[] = array( 'id' => (int) $r['id'], 'when' => gmdate( 'Y-m-d H:i:s', (int) $r['ts'] ), 'ip' => (string) $r['ip'], 'cc' => (string) $r['country'], 'method' => (string) $r['method'], 'path' => (string) $r['path'], 'status' => (int) $r['status'], 'kind' => (string) $r['kind'], 'user' => (int) $r['user_id'] > 0, 'ua' => (string) $r['user_agent'], ); } wp_send_json_success( array( 'rows' => $out, 'enabled' => self::is_enabled(), ) ); } /** * Switches recording on or off. * * @return void */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $on = isset( $_POST['enabled'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['enabled'] ) ); update_option( self::OPTION_ENABLED, $on ? '1' : '0', true ); wp_send_json_success( array( 'msg' => $on ? __( 'Saved. Recording every request that reaches WordPress.', 'cloudscale-cyber-devtools' ) : __( 'Saved. Recording is off; existing rows expire within a day.', 'cloudscale-cyber-devtools' ), 'enabled' => $on, ) ); } // ── Panel ──────────────────────────────────────────────────────────── /** * Renders the Live Traffic panel, on the Firewall tab below Recent Matches. * * The table body starts empty and is filled by cs-login.js, which polls only while this panel * is expanded and the browser tab is visible. A collapsed panel or a backgrounded tab costs the * server nothing. * * @return void */ public static function render_panel(): void { $on = self::is_enabled(); ?>