Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save patrickfreitasdev/8478f317a06ecb4c4a533df016d8a256 to your computer and use it in GitHub Desktop.

Select an option

Save patrickfreitasdev/8478f317a06ecb4c4a533df016d8a256 to your computer and use it in GitHub Desktop.
Defender Session Activity Fallback
<?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