= $max; } public static function hit( $scope, $identifier, $window_minutes ) { $key = self::key( $scope, $identifier ); $window = max( 1, absint( $window_minutes ) ) * MINUTE_IN_SECONDS; $hits = (int) get_transient( $key ); set_transient( $key, $hits + 1, $window ); self::remember( $key ); } /** * True the first time it is called for a key in a window, false afterwards. * * Used to write one security event per source per window instead of one per * refused request. A source hammering the form is refused by the counter for * nothing, but each refusal was still writing a row to the events table — * the one part of a throttled request that cost the site anything, and the * one an attacker could grow without limit. The refusal itself is unchanged; * only the note about it is now made once. * * @param string $scope What is being counted. * @param string $identifier Raw identifier; hashed before storage. * @param int $window_minutes How long one note lasts. * @return bool */ public static function first_in_window( $scope, $identifier, $window_minutes ) { $key = self::key( $scope, $identifier ); if ( get_transient( $key ) ) { return false; } set_transient( $key, 1, max( 1, absint( $window_minutes ) ) * MINUTE_IN_SECONDS ); self::remember( $key ); return true; } /** Option holding the keys this plugin has written, so they can be cleared. */ /** Prefix every counter this plugin writes shares. */ const TRANSIENT_PREFIX = 'careback_rl_'; /** Prefix the ban keys use, kept here so the clearing knows both. */ const BAN_PREFIX = 'careback_ban_'; const INDEX_OPTION = 'careback_limit_keys'; /** Most keys the index keeps. Beyond it the oldest are dropped. */ const INDEX_MAX = 2000; /** * Remembers a transient key this plugin set. * * Kept as an index rather than swept out of the options table, because on a * site with a persistent object cache transients are not in that table at * all — the sweep would find nothing and the button would silently do * nothing. The index works whatever the backend is. */ public static function remember( $key ) { $keys = (array) get_option( self::INDEX_OPTION, array() ); if ( in_array( $key, $keys, true ) ) { return; } $keys[] = $key; if ( count( $keys ) > self::INDEX_MAX ) { $keys = array_slice( $keys, -self::INDEX_MAX ); } update_option( self::INDEX_OPTION, $keys, false ); } /** * Drops every counter and ban the plugin holds, and reports how many. * * Deliberately blunt: the counters are keyed by a hash of the source, which * cannot be reversed, so there is no way to clear one visitor's and leave the * rest. It exists for testing a shop's own portal, where the tester keeps * locking themselves out. * * @return int Counters and bans removed. */ public static function clear_all() { $keys = (array) get_option( self::INDEX_OPTION, array() ); // The index only holds what was written since it existed, so a counter set // before the upgrade would survive a clearing that reported success. The // options table is swept as well: it is where transients live on a site // with no persistent object cache, which is most of them. Neither source // alone is enough — the index covers the cached case, the sweep covers the // upgraded one. foreach ( self::stored_keys() as $key ) { if ( ! in_array( $key, $keys, true ) ) { $keys[] = $key; } } $removed = 0; foreach ( $keys as $key ) { // A key whose window had already expired counts as nothing removed, so // the number reported is what was actually in force. if ( false !== get_transient( $key ) ) { $removed++; } delete_transient( $key ); } delete_option( self::INDEX_OPTION ); return $removed; } /** * Keys of this plugin's transients as the options table holds them. * * Empty on a site whose transients live in an object cache, which is why it * is one of two sources and not the only one. * * @return array */ private static function stored_keys() { global $wpdb; if ( ! isset( $wpdb->options ) || ! is_callable( array( $wpdb, 'esc_like' ) ) ) { return array(); } $out = array(); foreach ( array( self::TRANSIENT_PREFIX, self::BAN_PREFIX ) as $prefix ) { $names = (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- no WordPress API lists options by name prefix; read live. $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->esc_like( '_transient_' . $prefix ) . '%' ) ); foreach ( $names as $name ) { $out[] = substr( (string) $name, strlen( '_transient_' ) ); } } return $out; } public static function clear( $scope, $identifier ) { delete_transient( self::key( $scope, $identifier ) ); } /** * Identifiers are normalised before hashing — trimmed and lowercased — so * spacing and letter case cannot produce a fresh counter for the same value. */ private static function key( $scope, $identifier ) { $normalised = strtolower( trim( (string) $identifier ) ); return self::TRANSIENT_PREFIX . sanitize_key( $scope ) . '_' . substr( Hash::hmac( $normalised ), 0, 32 ); } }