'404ops-activity-log', 'tab' => 'sessions', self::RESULT_ARG => $result, ), admin_url( 'admin.php' ) ) ); exit; } /** * Decide and carry out one request. Returns a RESULTS code. * * @param mixed $request The unslashed POST body. */ public static function perform( $request ): string { $request = is_array( $request ) ? $request : array(); $op = isset( $request['op'] ) && is_string( $request['op'] ) ? sanitize_key( $request['op'] ) : ''; $user_id = isset( $request['user_id'] ) && is_scalar( $request['user_id'] ) ? absint( $request['user_id'] ) : 0; $nonce = isset( $request['_wpnonce'] ) && is_string( $request['_wpnonce'] ) ? sanitize_text_field( $request['_wpnonce'] ) : ''; if ( ! in_array( $op, array( self::OP_DESTROY_ONE, self::OP_DESTROY_USER, self::OP_DESTROY_OTHERS, self::OP_SAVE_POLICY ), true ) ) { return 'bad_request'; } if ( ! wp_verify_nonce( $nonce, self::nonce_action( $op, $user_id ) ) ) { return 'bad_nonce'; } if ( ! current_user_can( 'manage_options' ) ) { return 'forbidden'; } switch ( $op ) { case self::OP_SAVE_POLICY: return self::save_policy( $request ); case self::OP_DESTROY_OTHERS: return self::destroy_others( $user_id ); case self::OP_DESTROY_USER: return self::destroy_user( $user_id ); default: $handle = isset( $request['session'] ) && is_string( $request['session'] ) ? $request['session'] : ''; return self::destroy_one( $user_id, $handle ); } } /** * Whether the acting user may end $target's sessions (rule 3 above). * * @param \WP_User $target Whose sessions. */ public static function may_manage( \WP_User $target ): bool { return current_user_can( 'edit_user', $target->ID ); } /** * Whether the acting user may change the policy (see the class docblock). */ public static function may_edit_policy(): bool { return current_user_can( 'manage_options' ) && ( ! is_multisite() || current_user_can( 'manage_network_options' ) ); } /** * @param array $request The unslashed POST body. */ private static function save_policy( array $request ): string { if ( ! self::may_edit_policy() ) { return 'forbidden'; } $raw = array( 'idle_minutes' => isset( $request['idle_minutes'] ) && is_scalar( $request['idle_minutes'] ) ? sanitize_text_field( (string) $request['idle_minutes'] ) : '0', 'roles' => array(), ); // Only roles that exist on this site are ever stored. $known = array_keys( wp_roles()->get_names() ); $roles = isset( $request['roles'] ) && is_array( $request['roles'] ) ? $request['roles'] : array(); foreach ( $known as $role ) { $entry = isset( $roles[ $role ] ) && is_array( $roles[ $role ] ) ? $roles[ $role ] : array(); $raw['roles'][ $role ] = array( 'idle' => isset( $entry['idle'] ) && is_scalar( $entry['idle'] ) ? sanitize_text_field( (string) $entry['idle'] ) : '', 'limit' => isset( $entry['limit'] ) && is_scalar( $entry['limit'] ) ? sanitize_text_field( (string) $entry['limit'] ) : '0', ); } // SessionLog records the change on update_option_ / add_option_. update_option( SessionPolicy::OPTION, SessionPolicy::sanitize( $raw ), true ); return 'policy_saved'; } /** * "Log out everywhere else": the acting user's other sessions. * * @param int $user_id Must be the acting user. */ private static function destroy_others( int $user_id ): string { $me = wp_get_current_user(); if ( $user_id !== $me->ID ) { return 'forbidden'; } $token = wp_get_session_token(); if ( '' === $token ) { return 'no_current_session'; } $manager = \WP_Session_Tokens::get_instance( $me->ID ); $others = max( 0, count( $manager->get_all() ) - 1 ); $manager->destroy_others( $token ); SessionLog::sessions_ended( $me, $others, true, 'sessions_screen' ); return 'destroyed_others'; } /** * Every session of ANOTHER user. * * @param int $user_id The target. */ private static function destroy_user( int $user_id ): string { $target = get_userdata( $user_id ); if ( ! $target instanceof \WP_User ) { return 'gone'; } if ( get_current_user_id() === $target->ID ) { return 'own_current'; } if ( ! self::may_manage( $target ) ) { return 'forbidden'; } $manager = \WP_Session_Tokens::get_instance( $target->ID ); $count = count( $manager->get_all() ); $manager->destroy_all(); SessionLog::sessions_ended( $target, $count, false, 'sessions_screen' ); return 'destroyed_user'; } /** * One session, named by its handle. * * @param int $user_id The session's owner. * @param string $handle SessionStore::handle() of it. */ private static function destroy_one( int $user_id, string $handle ): string { if ( ! SessionStore::supported() ) { return 'unsupported'; } $target = get_userdata( $user_id ); if ( ! $target instanceof \WP_User ) { return 'gone'; } $is_me = get_current_user_id() === $target->ID; if ( ! $is_me && ! self::may_manage( $target ) ) { return 'forbidden'; } $verifier = SessionStore::verifier_for_handle( $target->ID, $handle ); if ( null === $verifier ) { return 'gone'; } if ( $is_me && hash_equals( SessionStore::current_verifier(), $verifier ) ) { return 'own_current'; } $session = SessionStore::sessions( $target->ID )[ $verifier ] ?? array(); if ( ! SessionStore::destroy( $target->ID, $verifier ) ) { return 'gone'; } SessionLog::session_ended( $target, $session ); return 'destroyed_one'; } /** * Records core's own "Log Out Everywhere" (user-edit screen) and "Log * Out Everywhere Else" (profile screen) button presses, which end * sessions through WP_Session_Tokens and fire no action of their own. * * Mirrors wp_ajax_destroy_sessions()'s checks exactly -- the user * exists, `edit_user` on them, and its `update-user_{id}` nonce -- and * logs only a request that core is about to carry out. Verifying the * nonce here does not consume it; WordPress nonces are not one-time. */ /** * Core's user-edit nonce action, `update-user_{id}`, for an already * resolved user id. * * Its own method on purpose: semgrep's tainted-sql-string rule reads a * literal starting with a SQL keyword ("update") concatenated with request * data as a SQL string. This is a nonce action name, not SQL; building it * from a typed int in its own frame keeps the scanner's rule intact (the * estate never weakens a guard for a name collision -- OPERATIONS.md) while * the call site stays the exact check wp_ajax_destroy_sessions() makes. */ private static function core_user_nonce_action( int $user_id ): string { return sprintf( 'update-user_%d', $user_id ); } public static function log_core_destroy_sessions(): void { // phpcs:disable WordPress.Security.NonceVerification.Missing -- the nonce IS verified below, against core's own action. $user_id = isset( $_POST['user_id'] ) && is_scalar( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0; $nonce = isset( $_POST['nonce'] ) && is_string( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; // phpcs:enable $user = $user_id > 0 ? get_userdata( $user_id ) : false; if ( ! $user instanceof \WP_User || ! current_user_can( 'edit_user', $user->ID ) || ! wp_verify_nonce( $nonce, self::core_user_nonce_action( $user->ID ) ) ) { return; } $manager = \WP_Session_Tokens::get_instance( $user->ID ); $count = count( $manager->get_all() ); $is_me = get_current_user_id() === $user->ID; if ( $is_me ) { $count = max( 0, $count - ( '' !== wp_get_session_token() ? 1 : 0 ) ); } SessionLog::sessions_ended( $user, $count, $is_me, 'profile_screen' ); } }