Created
October 30, 2026 00:49
-
-
Save patrickfreitasdev/8478f317a06ecb4c4a533df016d8a256 to your computer and use it in GitHub Desktop.
Defender Session Activity Fallback
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: Defender Session Activity Fallback | |
| * Description: Keeps Defender's idle-activity timestamp in user meta when APCu drops the transient. Temporary bypass; does not modify Defender. | |
| * Version: 1.0.0 | |
| * | |
| * Install: copy this file to wp-content/mu-plugins/wpdef-session-activity-fallback.php | |
| * It loads automatically. Set WPDEF_SESSION_ACTIVITY_FALLBACK to false to disable it. | |
| * | |
| * Remove this file once Defender stores last activity somewhere that survives an object-cache flush. | |
| * | |
| * @package WPDEF_Session_Activity_Fallback | |
| */ | |
| if ( ! defined( 'ABSPATH' ) ) { | |
| exit; | |
| } | |
| if ( defined( 'WPDEF_SESSION_ACTIVITY_FALLBACK' ) && ! WPDEF_SESSION_ACTIVITY_FALLBACK ) { | |
| return; | |
| } | |
| /** | |
| * Restores wpdef_last_act_* from user meta before Defender's idle check. | |
| */ | |
| final class WPDEF_Session_Activity_Fallback { | |
| const VERSION = '1.0.0'; | |
| /** | |
| * Register hooks before Defender's init callback at priority 10. | |
| * | |
| * @return void | |
| */ | |
| public static function boot() { | |
| add_action( 'init', array( __CLASS__, 'restore' ), 9 ); | |
| add_action( 'init', array( __CLASS__, 'persist' ), 11 ); | |
| add_action( 'set_logged_in_cookie', array( __CLASS__, 'persist_on_login' ), 20, 6 ); | |
| add_action( 'wpdef_session_timeout', array( __CLASS__, 'forget' ) ); | |
| add_action( 'wp_logout', array( __CLASS__, 'forget' ) ); | |
| } | |
| /** | |
| * Put a database-backed timestamp back into the transient Defender is about to read. | |
| * | |
| * @return void | |
| */ | |
| public static function restore() { | |
| if ( ! self::should_track() ) { | |
| return; | |
| } | |
| $user_id = get_current_user_id(); | |
| $token = (string) wp_get_session_token(); | |
| $key = self::activity_key( $user_id, $token ); | |
| $cached = get_transient( $key ); | |
| if ( is_numeric( $cached ) && (int) $cached > 0 ) { | |
| return; | |
| } | |
| $stored = self::stored_activity( $user_id, $token ); | |
| if ( $stored <= 0 ) { | |
| return; | |
| } | |
| set_transient( $key, $stored, self::ttl( $user_id, $token ) ); | |
| self::log( | |
| 'restored ' . $key . ' from user meta, age=' . ( time() - $stored ) . 's. Defender would have treated this cache miss as idle since timestamp 1.' | |
| ); | |
| } | |
| /** | |
| * Copy the timestamp Defender just wrote into user meta. | |
| * | |
| * The transient lives only in the object cache. User meta is reloaded from | |
| * the database after APCu is cleared. | |
| * | |
| * @return void | |
| */ | |
| public static function persist() { | |
| if ( ! self::should_track() ) { | |
| return; | |
| } | |
| $user_id = get_current_user_id(); | |
| $token = (string) wp_get_session_token(); | |
| $cached = get_transient( self::activity_key( $user_id, $token ) ); | |
| $stamp = ( is_numeric( $cached ) && (int) $cached > 0 ) ? (int) $cached : time(); | |
| self::write_meta( $user_id, $token, $stamp, false ); | |
| } | |
| /** | |
| * Save activity for a token that is not in $_COOKIE yet. | |
| * | |
| * @param string $logged_in_cookie Cookie value. Not stored. | |
| * @param int $expire Unused. | |
| * @param int $expiration Unused. | |
| * @param int $user_id User ID. | |
| * @param string $scheme Unused. | |
| * @param string $token Session token. Not stored. | |
| * | |
| * @return void | |
| */ | |
| public static function persist_on_login( $logged_in_cookie, $expire, $expiration, $user_id, $scheme, $token ) { | |
| unset( $logged_in_cookie, $expire, $expiration, $scheme ); | |
| $user_id = (int) $user_id; | |
| $token = (string) $token; | |
| if ( $user_id <= 0 || '' === $token ) { | |
| return; | |
| } | |
| $cached = get_transient( self::activity_key( $user_id, $token ) ); | |
| $stamp = ( is_numeric( $cached ) && (int) $cached > 0 ) ? (int) $cached : time(); | |
| self::write_meta( $user_id, $token, $stamp, true ); | |
| } | |
| /** | |
| * Drop the saved timestamp when Defender ends the session. | |
| * | |
| * @param int|null $user_id User ID when Defender passes one. | |
| * | |
| * @return void | |
| */ | |
| public static function forget( $user_id = null ) { | |
| $user_id = $user_id ? (int) $user_id : get_current_user_id(); | |
| $token = function_exists( 'wp_get_session_token' ) ? (string) wp_get_session_token() : ''; | |
| if ( $user_id <= 0 || '' === $token ) { | |
| return; | |
| } | |
| delete_user_meta( $user_id, self::meta_key( $token ) ); | |
| } | |
| /** | |
| * Same requests Defender's idle check actually evaluates. | |
| * | |
| * @return bool | |
| */ | |
| private static function should_track() { | |
| if ( ! is_user_logged_in() || '' === (string) wp_get_session_token() ) { | |
| return false; | |
| } | |
| if ( wp_doing_ajax() || wp_doing_cron() ) { | |
| return false; | |
| } | |
| if ( function_exists( 'defender_is_rest_api_request' ) && defender_is_rest_api_request() ) { | |
| return false; | |
| } | |
| $service = self::session_service(); | |
| if ( ! $service ) { | |
| return false; | |
| } | |
| if ( $service->request_is_from_server() ) { | |
| return false; | |
| } | |
| return $service->can_apply_session_protection(); | |
| } | |
| /** | |
| * @return \WP_Defender\Component\Session_Protection|null | |
| */ | |
| private static function session_service() { | |
| if ( ! function_exists( 'wd_di' ) || ! class_exists( '\WP_Defender\Component\Session_Protection' ) ) { | |
| return null; | |
| } | |
| try { | |
| $service = wd_di()->get( \WP_Defender\Component\Session_Protection::class ); | |
| } catch ( \Throwable $e ) { | |
| return null; | |
| } | |
| return $service instanceof \WP_Defender\Component\Session_Protection ? $service : null; | |
| } | |
| /** | |
| * Last activity that does not depend on APCu. | |
| * | |
| * User meta is preferred. The WordPress session login time covers the first | |
| * request after this file is installed, before any meta has been written. | |
| * | |
| * @param int $user_id User ID. | |
| * @param string $token Session token. | |
| * | |
| * @return int | |
| */ | |
| private static function stored_activity( $user_id, $token ) { | |
| $meta = get_user_meta( $user_id, self::meta_key( $token ), true ); | |
| if ( is_numeric( $meta ) && (int) $meta > 0 ) { | |
| return (int) $meta; | |
| } | |
| $session = \WP_Session_Tokens::get_instance( $user_id )->get( $token ); | |
| if ( is_array( $session ) && isset( $session['login'] ) && is_numeric( $session['login'] ) && (int) $session['login'] > 0 ) { | |
| return (int) $session['login']; | |
| } | |
| return 0; | |
| } | |
| /** | |
| * @param int $user_id User ID. | |
| * @param string $token Session token. | |
| * @param int $stamp Timestamp to store. | |
| * @param bool $force Write even when the saved value is less than a minute old. | |
| * | |
| * @return void | |
| */ | |
| private static function write_meta( $user_id, $token, $stamp, $force ) { | |
| $key = self::meta_key( $token ); | |
| $existing = get_user_meta( $user_id, $key, true ); | |
| if ( | |
| ! $force | |
| && is_numeric( $existing ) | |
| && (int) $existing > 0 | |
| && ( $stamp - (int) $existing ) < MINUTE_IN_SECONDS | |
| ) { | |
| return; | |
| } | |
| update_user_meta( $user_id, $key, $stamp ); | |
| } | |
| /** | |
| * Match Defender's transient name. The raw token is never used as a key. | |
| * | |
| * @param int $user_id User ID. | |
| * @param string $token Session token. | |
| * | |
| * @return string | |
| */ | |
| private static function activity_key( $user_id, $token ) { | |
| return 'wpdef_last_act_' . (int) $user_id . '_' . substr( md5( $token ), 0, 16 ); | |
| } | |
| /** | |
| * @param string $token Session token. | |
| * | |
| * @return string | |
| */ | |
| private static function meta_key( $token ) { | |
| return 'wpdef_idle_' . substr( md5( $token ), 0, 16 ); | |
| } | |
| /** | |
| * @param int $user_id User ID. | |
| * @param string $token Session token. | |
| * | |
| * @return int | |
| */ | |
| private static function ttl( $user_id, $token ) { | |
| $session = \WP_Session_Tokens::get_instance( $user_id )->get( $token ); | |
| if ( is_array( $session ) && isset( $session['expiration'] ) ) { | |
| $remaining = (int) $session['expiration'] - time(); | |
| if ( $remaining > 0 ) { | |
| return $remaining; | |
| } | |
| } | |
| return DAY_IN_SECONDS; | |
| } | |
| /** | |
| * @param string $message Log line. | |
| * | |
| * @return void | |
| */ | |
| private static function log( $message ) { | |
| if ( ! function_exists( 'wp_upload_dir' ) ) { | |
| return; | |
| } | |
| $upload = wp_upload_dir( null, false ); | |
| $base = ! empty( $upload['basedir'] ) ? $upload['basedir'] : WP_CONTENT_DIR . '/uploads'; | |
| $dir = $base . '/wp-defender'; | |
| if ( ! is_dir( $dir ) && function_exists( 'wp_mkdir_p' ) ) { | |
| wp_mkdir_p( $dir ); | |
| } | |
| $path = $dir . '/session-lockout-debug.log'; | |
| $line = '[' . gmdate( 'c' ) . '] FALLBACK v' . self::VERSION . ' ' . $message . "\n"; | |
| file_put_contents( $path, $line, FILE_APPEND | LOCK_EX ); | |
| } | |
| } | |
| WPDEF_Session_Activity_Fallback::boot(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment