phpwordpresswordpress-rest-apirate-limitingsession-management

How to implement session-based rate limiting instead of IP-based for WordPress plugin?


I'm developing a WordPress plugin that provides chat functionality via REST API endpoints. Currently, I'm using IP-based rate limiting, but this causes issues in university/company environments where multiple users share the same public IP address behind a router.

Current IP-based rate limiting code:

private function get_client_ip() {
    $ip_keys = [
        'HTTP_X_REAL_IP',
        'HTTP_X_FORWARDED_FOR', 
        'HTTP_CLIENT_IP',
        'REMOTE_ADDR'
    ];
    
    foreach ($ip_keys as $key) {
        if (!empty($_SERVER[$key])) {
            $ip = $_SERVER[$key];
            if (strpos($ip, ',') !== false) {
                $ips = explode(',', $ip);
                $ip = trim($ips[0]);
            }
            if (filter_var($ip, FILTER_VALIDATE_IP)) {
                return $ip;
            }
        }
    }
    return '0.0.0.0';
}

private function check_daily_limit($ip) {
    $daily_limit = $this->rate_limits['daily'];
    $limit_key = 'chat2find_daily_limit_' . md5($ip);
    
    $data = get_transient($limit_key);
    
    if ($data === false) {
        $data = [
            'count' => 1,
            'first_request' => time(),
            'ip' => $ip,
            'endpoint' => 'daily'
        ];
        set_transient($limit_key, $data, $daily_limit['seconds']);
    } else {
        if ($data['count'] >= $daily_limit['requests']) {
            $wait_time = $daily_limit['seconds'] - (time() - $data['first_request']);
            return new WP_Error('daily_rate_limit_exceeded', 
                sprintf('Daily API limit exceeded. Please try again in %d hours.', ceil($wait_time / 3600)),
                ['status' => 429]
            );
        }
        $data['count']++;
        set_transient($limit_key, $data, $daily_limit['seconds']);
    }
    return true;
}

The Problem:
In environments like universities or companies, multiple users share the same public IP, so legitimate users get blocked when someone else from the same network exceeds the rate limit.

What I Need:
I want to implement session-based rate limiting that:

  1. Works for both logged-in and non-logged-in users

  2. Doesn't require user authentication

  3. Uses session cookies or browser fingerprints

My Question:

How can I generate and track unique session identifiers securely?


Solution

  • I have added a small helper here to create/return a stable, server-generated session id stored in a cookie and I changed check_daily_limit to use that session id as the rate-limit key when available (falling back to IP). I kept the rest of your transient logic intact so the change is minimal and safe to drop into your plugin.

    Updated code (only the required parts)

    private function get_session_identifier() {
        $cookie_name = 'chat2find_sid';
    
        // If cookie exists and looks valid, return it
        if (!empty($_COOKIE[$cookie_name])) {
            $sid = $_COOKIE[$cookie_name];
            // validate expected hex format (32 or 64 hex chars)
            if (preg_match('/^[0-9a-f]{32,64}$/', $sid)) {
                return 'session_' . $sid;
            }
            // invalid cookie: fall through to re-create
        }
    
        // Create a new session id (32 hex chars = 16 bytes)
        try {
            $sid = bin2hex(random_bytes(16));
        } catch (Exception $e) {
            // random_bytes failed (very unlikely) -> no session id
            return '';
        }
    
        // Cookie lifetime: adjust as needed (example: 30 days)
        $lifetime = 30 * DAY_IN_SECONDS;
        $expire = time() + $lifetime;
    
        // Set cookie with appropriate flags (PHP 7.3+ supports options array)
        $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
        if (PHP_VERSION_ID >= 70300) {
            setcookie($cookie_name, $sid, [
                'expires' => $expire,
                'path' => '/',
                'secure' => $secure,
                'httponly' => true,
                'samesite' => 'Lax',
            ]);
        } else {
            // Best-effort fallback for older PHP: craft header
            $cookie = rawurlencode($cookie_name) . '=' . rawurlencode($sid)
                . '; Expires=' . gmdate('D, d M Y H:i:s T', $expire)
                . '; Path=/'
                . ($secure ? '; Secure' : '')
                . '; HttpOnly; SameSite=Lax';
            header('Set-Cookie: ' . $cookie, false);
        }
    
        return 'session_' . $sid;
    }
    
    private function check_daily_limit($ip) {
        $daily_limit = $this->rate_limits['daily'];
    
        // Prefer a stable session identifier for anonymous users (avoids shared-IP blocking).
        // Falls back to IP when session id cannot be created (very rare).
        $identifier = $this->get_session_identifier();
        if (empty($identifier)) {
            $identifier = 'ip_' . $ip;
        }
    
        $limit_key = 'chat2find_daily_limit_' . md5($identifier);
    
        $data = get_transient($limit_key);
    
        if ($data === false) {
            $data = [
                'count' => 1,
                'first_request' => time(),
                'identifier' => $identifier,
                'endpoint' => 'daily'
            ];
            set_transient($limit_key, $data, $daily_limit['seconds']);
        } else {
            if ($data['count'] >= $daily_limit['requests']) {
                $wait_time = $daily_limit['seconds'] - (time() - $data['first_request']);
                return new WP_Error('daily_rate_limit_exceeded',
                    sprintf('Daily API limit exceeded. Please try again in %d hours.', ceil($wait_time / 3600)),
                    ['status' => 429]
                );
            }
            $data['count']++;
            set_transient($limit_key, $data, $daily_limit['seconds']);
        }
        return true;
    }