query( $wpdb->prepare( "INSERT INTO %i (bucket, subject, hits, expires_at) VALUES (%s, %s, 1, %s) ON DUPLICATE KEY UPDATE hits = IF(expires_at < %s, 1, hits + 1), expires_at = IF(expires_at < %s, VALUES(expires_at), expires_at)", $table, $bucket, $subject, $expires, $now, $now ) ); $hits = (int) $wpdb->get_var( $wpdb->prepare( "SELECT hits FROM %i WHERE bucket = %s AND subject = %s", $table, $bucket, $subject ) ); // phpcs:enable return $hits <= max( 1, $max ); } /** * Seconds until the current window lapses, or zero when nothing is pending. * * Used to tell the customer exactly how long to wait rather than an unhelpful * "try again later". * * @param string $bucket Limit name. * @param string $subject Limited subject. */ public static function secondsRemaining( string $bucket, string $subject ): int { global $wpdb; $table = Schema::table( 'rate' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $expires = $wpdb->get_var( $wpdb->prepare( "SELECT expires_at FROM %i WHERE bucket = %s AND subject = %s", $table, $bucket, substr( $subject, 0, 191 ) ) ); if ( ! is_string( $expires ) ) { return 0; } return max( 0, (int) strtotime( $expires . ' UTC' ) - time() ); } /** * Hand back one hit. * * For an attempt that should not have counted — a login code the store failed * to deliver. Never takes a counter below zero. * * @param string $bucket Limit name. * @param string $subject Limited subject. */ public static function refund( string $bucket, string $subject ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( "UPDATE %i SET hits = hits - 1 WHERE bucket = %s AND subject = %s AND hits > 0", Schema::table( 'rate' ), $bucket, substr( $subject, 0, 191 ) ) ); } /** * Clear a counter. * * Called after a successful verification: someone who just proved they own * the number should not be locked out by the attempts it took to get there. * * @param string $bucket Limit name. * @param string $subject Limited subject. */ public static function clear( string $bucket, string $subject ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->delete( Schema::table( 'rate' ), [ 'bucket' => $bucket, 'subject' => substr( $subject, 0, 191 ), ], [ '%s', '%s' ] ); } /** * Delete lapsed rows. * * Run from the queue rather than opportunistically on a request, so a visitor * never pays for housekeeping. * * @return int Rows removed. */ public static function purgeExpired(): int { global $wpdb; $table = Schema::table( 'rate' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at < %s', $table, gmdate( 'Y-m-d H:i:s' ) ) ); } /** * The client's IP address. * * Proxy headers are only trusted when the site opts in. Reading * `X-Forwarded-For` unconditionally would hand every attacker a trivial way to * bypass the limiter: they simply send a different value each request. */ public static function clientIp(): string { /** * Filters whether proxy-supplied IP headers may be trusted. * * Enable this only when the site genuinely sits behind a proxy that * overwrites the header — Cloudflare, a load balancer. Otherwise the * header is attacker-controlled. * * @param bool $trust Whether to trust forwarding headers. */ $trustProxy = (bool) apply_filters( 'rahkar_wp_trust_proxy_headers', false ); if ( $trustProxy ) { foreach ( [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR' ] as $header ) { if ( empty( $_SERVER[ $header ] ) ) { continue; } $value = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); $first = trim( explode( ',', $value )[0] ); if ( filter_var( $first, FILTER_VALIDATE_IP ) ) { return $first; } } } $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return filter_var( $remote, FILTER_VALIDATE_IP ) ? $remote : '0.0.0.0'; } }