tigerstyle-scent/includes/class-rate-limiter.php
Ryan Malloy 120f0b616d Add release tooling and update for v1.0.0 release
- Add .distignore (operator-private files excluded)
- Add build.sh for WordPress-installable release ZIPs
- Update CLAUDE.md references (now operator-private only)
2026-05-27 14:32:07 -06:00

337 lines
13 KiB
PHP

<?php
/**
* TigerStyle Scent Rate Limiter
* Implements secure rate limiting for OAuth2 endpoints to prevent abuse
*
* @package TigerStyle Scent
*/
defined('ABSPATH') or die('Direct access forbidden.');
class TigerStyleScent_RateLimiter {
/**
* Rate limiting configurations for different endpoints
* @var array
*/
private static $rate_limits = [
'oauth_authorize' => [
'limit' => 30, // 30 requests
'window' => 3600, // per hour
'block_duration' => 3600 // 1 hour block
],
'oauth_token' => [
'limit' => 60, // 60 requests
'window' => 3600, // per hour
'block_duration' => 1800 // 30 min block
],
'oauth_introspect' => [
'limit' => 100, // 100 requests
'window' => 3600, // per hour
'block_duration' => 900 // 15 min block
],
'oauth_revoke' => [
'limit' => 20, // 20 requests
'window' => 3600, // per hour
'block_duration' => 1800 // 30 min block
]
];
/**
* Check if request is within rate limits
*
* @param string $endpoint OAuth2 endpoint (authorize, token, introspect, revoke)
* @param string $identifier IP address or client identifier
* @return bool True if within limits, false if rate limited
*/
public static function check_rate_limit(string $endpoint, string $identifier = null): bool {
// Get client identifier (IP address or authenticated client)
if (!$identifier) {
$identifier = self::get_client_identifier();
}
$endpoint_key = 'oauth_' . $endpoint;
// Check if endpoint has rate limiting configured
if (!isset(self::$rate_limits[$endpoint_key])) {
return true; // No rate limiting configured
}
$config = self::$rate_limits[$endpoint_key];
// Check if client is currently blocked
if (self::is_blocked($endpoint_key, $identifier)) {
self::log_rate_limit_violation($endpoint_key, $identifier, 'blocked');
return false;
}
// Get current request count
$current_count = self::get_request_count($endpoint_key, $identifier);
// 🔐 SECURITY: Check if limit exceeded with progressive delays
if ($current_count >= $config['limit']) {
// Calculate progressive block duration based on violations
$violation_count = self::get_violation_count($endpoint_key, $identifier);
$progressive_duration = self::calculate_progressive_duration($config['block_duration'], $violation_count);
// Block the client with progressive duration
self::block_client($endpoint_key, $identifier, $progressive_duration);
self::increment_violation_count($endpoint_key, $identifier);
self::log_rate_limit_violation($endpoint_key, $identifier, 'limit_exceeded');
return false;
}
// Add progressive delay for clients approaching limit
if ($current_count > ($config['limit'] * 0.8)) {
$delay = min(2, ($current_count / $config['limit']) * 2); // Max 2 second delay
if ($delay > 0.1) {
usleep($delay * 1000000); // Convert to microseconds
}
}
// Increment request count
self::increment_request_count($endpoint_key, $identifier, $config['window']);
return true;
}
/**
* Get unique client identifier for rate limiting
*
* @return string Client identifier
*/
private static function get_client_identifier(): string {
// 🔐 SECURITY: Enhanced client fingerprinting to prevent spoofing
$ip = self::get_client_ip();
// Create more robust fingerprint using multiple headers
$fingerprint_data = [
'ip' => $ip,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'accept_language' => $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '',
'accept_encoding' => $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '',
'connection' => $_SERVER['HTTP_CONNECTION'] ?? '',
];
// Use HMAC with a secret key for tamper resistance
$secret_key = defined('TIGERSTYLE_SCENT_SECRET_KEY') ? TIGERSTYLE_SCENT_SECRET_KEY : wp_salt('auth');
$fingerprint = hash_hmac('sha256', json_encode($fingerprint_data), $secret_key);
return $ip . '_' . substr($fingerprint, 0, 16);
}
/**
* Get real client IP address (accounting for proxies)
*
* @return string IP address
*/
private static function get_client_ip(): string {
// Check for IP from various headers (in order of preference)
$ip_headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_REAL_IP', // Nginx proxy
'HTTP_X_FORWARDED_FOR', // Standard proxy header
'HTTP_X_FORWARDED', // Alternative
'HTTP_X_CLUSTER_CLIENT_IP', // Cluster
'HTTP_FORWARDED_FOR', // Alternative
'HTTP_FORWARDED', // RFC 7239
'REMOTE_ADDR' // Standard
];
foreach ($ip_headers as $header) {
if (!empty($_SERVER[$header])) {
$ip = $_SERVER[$header];
// Handle comma-separated IPs (take first one)
if (strpos($ip, ',') !== false) {
$ip = trim(explode(',', $ip)[0]);
}
// Validate IP address
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
}
// Fallback to REMOTE_ADDR (even if private)
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
/**
* Check if client is currently blocked
*
* @param string $endpoint_key Endpoint identifier
* @param string $identifier Client identifier
* @return bool True if blocked
*/
private static function is_blocked(string $endpoint_key, string $identifier): bool {
$block_key = "tigerstyle_scent_block_{$endpoint_key}_{$identifier}";
return (bool) get_transient($block_key);
}
/**
* Block client for specified duration
*
* @param string $endpoint_key Endpoint identifier
* @param string $identifier Client identifier
* @param int $duration Block duration in seconds
*/
private static function block_client(string $endpoint_key, string $identifier, int $duration): void {
$block_key = "tigerstyle_scent_block_{$endpoint_key}_{$identifier}";
set_transient($block_key, time(), $duration);
// Also clear request count when blocking
$count_key = "tigerstyle_scent_count_{$endpoint_key}_{$identifier}";
delete_transient($count_key);
}
/**
* Get current request count for client
*
* @param string $endpoint_key Endpoint identifier
* @param string $identifier Client identifier
* @return int Current request count
*/
private static function get_request_count(string $endpoint_key, string $identifier): int {
$count_key = "tigerstyle_scent_count_{$endpoint_key}_{$identifier}";
return (int) get_transient($count_key);
}
/**
* Increment request count for client
*
* @param string $endpoint_key Endpoint identifier
* @param string $identifier Client identifier
* @param int $window Time window in seconds
*/
private static function increment_request_count(string $endpoint_key, string $identifier, int $window): void {
$count_key = "tigerstyle_scent_count_{$endpoint_key}_{$identifier}";
$current_count = self::get_request_count($endpoint_key, $identifier);
set_transient($count_key, $current_count + 1, $window);
}
/**
* Log rate limit violation for monitoring
*
* @param string $endpoint_key Endpoint identifier
* @param string $identifier Client identifier
* @param string $violation_type Type of violation
*/
private static function log_rate_limit_violation(string $endpoint_key, string $identifier, string $violation_type): void {
$log_data = [
'endpoint' => $endpoint_key,
'client' => $identifier,
'violation' => $violation_type,
'timestamp' => current_time('mysql'),
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
];
// Log to WordPress error log if debug enabled
if (defined('TIGERSTYLE_SCENT_DEBUG') && TIGERSTYLE_SCENT_DEBUG) {
error_log('[TigerStyle Scent Rate Limit] ' . json_encode($log_data));
}
// Fire action for external monitoring systems
do_action('tigerstyle_scent_rate_limit_violation', $log_data);
}
/**
* Send rate limit exceeded response
*
* @param string $endpoint Endpoint name
*/
public static function send_rate_limit_response(string $endpoint): void {
$config = self::$rate_limits['oauth_' . $endpoint] ?? [];
$retry_after = $config['block_duration'] ?? 3600;
http_response_code(429);
header('Content-Type: application/json');
header('Retry-After: ' . $retry_after);
header('X-RateLimit-Limit: ' . ($config['limit'] ?? 'N/A'));
header('X-RateLimit-Window: ' . ($config['window'] ?? 'N/A'));
echo json_encode([
'error' => 'rate_limit_exceeded',
'error_description' => 'Too many requests. Territory access temporarily restricted.',
'retry_after' => $retry_after
]);
exit;
}
/**
* Get rate limit status for client
*
* @param string $endpoint Endpoint name
* @param string $identifier Client identifier
* @return array Rate limit status
*/
public static function get_rate_limit_status(string $endpoint, string $identifier = null): array {
if (!$identifier) {
$identifier = self::get_client_identifier();
}
$endpoint_key = 'oauth_' . $endpoint;
$config = self::$rate_limits[$endpoint_key] ?? [];
if (empty($config)) {
return ['rate_limited' => false];
}
$current_count = self::get_request_count($endpoint_key, $identifier);
$is_blocked = self::is_blocked($endpoint_key, $identifier);
return [
'rate_limited' => $is_blocked || ($current_count >= $config['limit']),
'current_count' => $current_count,
'limit' => $config['limit'],
'window' => $config['window'],
'remaining' => max(0, $config['limit'] - $current_count),
'blocked' => $is_blocked
];
}
/**
* Get violation count for progressive penalties
*
* @param string $endpoint_key Endpoint identifier
* @param string $identifier Client identifier
* @return int Violation count
*/
private static function get_violation_count(string $endpoint_key, string $identifier): int {
$violation_key = "tigerstyle_scent_violations_{$endpoint_key}_{$identifier}";
return (int) get_transient($violation_key);
}
/**
* Increment violation count for progressive penalties
*
* @param string $endpoint_key Endpoint identifier
* @param string $identifier Client identifier
*/
private static function increment_violation_count(string $endpoint_key, string $identifier): void {
$violation_key = "tigerstyle_scent_violations_{$endpoint_key}_{$identifier}";
$current_violations = self::get_violation_count($endpoint_key, $identifier);
// Violations expire after 24 hours
set_transient($violation_key, $current_violations + 1, DAY_IN_SECONDS);
}
/**
* Calculate progressive block duration based on violation history
*
* @param int $base_duration Base block duration in seconds
* @param int $violation_count Number of previous violations
* @return int Progressive duration in seconds
*/
private static function calculate_progressive_duration(int $base_duration, int $violation_count): int {
// Progressive multiplier: 1x, 2x, 4x, 8x, max 24 hours
$multiplier = min(pow(2, $violation_count), 24);
$progressive_duration = $base_duration * $multiplier;
// Cap at 24 hours maximum
return min($progressive_duration, DAY_IN_SECONDS);
}
}