Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.89% covered (warning)
88.89%
72 / 81
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
RateLimitMiddleware
88.89% covered (warning)
88.89%
72 / 81
33.33% covered (danger)
33.33%
2 / 6
26.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 loadRouteConfigs
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
4.25
 process
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
6.12
 getRouteConfig
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 updateRateLimit
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 logViolation
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3
4namespace App\Middleware;
5
6use App\Http\Exception\TooManyRequestsException;
7use App\Utility\SettingsManager;
8use Cake\Cache\Cache;
9use Cake\Log\Log;
10use Psr\Http\Message\ResponseInterface;
11use Psr\Http\Message\ServerRequestInterface;
12use Psr\Http\Server\MiddlewareInterface;
13use Psr\Http\Server\RequestHandlerInterface;
14
15class RateLimitMiddleware implements MiddlewareInterface
16{
17    /**
18     * @var array<string, array{limit: int, period: int}> Route-specific configurations
19     */
20    private array $routeConfigs = [];
21
22    /**
23     * @var int Default limit for routes not specifically configured
24     */
25    private int $defaultLimit;
26
27    /**
28     * @var int Default period for routes not specifically configured
29     */
30    private int $defaultPeriod;
31
32    /**
33     * @var bool Whether rate limiting is enabled
34     */
35    private bool $enabled;
36
37    /**
38     * Constructor
39     *
40     * @param array $config Configuration options (can be overridden for testing)
41     */
42    public function __construct(array $config = [])
43    {
44        // Check if rate limiting is enabled
45        $this->enabled = (bool)SettingsManager::read('Security.enableRateLimiting', true);
46
47        // Load global defaults from settings
48        $this->defaultLimit = (int)SettingsManager::read('RateLimit.numberOfRequests', 100);
49        $this->defaultPeriod = (int)SettingsManager::read('RateLimit.numberOfSeconds', 60);
50
51        // Load route-specific configurations
52        $this->loadRouteConfigs();
53
54        // Allow config overrides (useful for testing)
55        if (!empty($config)) {
56            if (isset($config['enabled'])) {
57                $this->enabled = $config['enabled'];
58            }
59            if (isset($config['defaultLimit'])) {
60                $this->defaultLimit = $config['defaultLimit'];
61            }
62            if (isset($config['defaultPeriod'])) {
63                $this->defaultPeriod = $config['defaultPeriod'];
64            }
65            if (isset($config['routes'])) {
66                $this->routeConfigs = array_merge($this->routeConfigs, $config['routes']);
67            }
68        }
69    }
70
71    /**
72     * Load route-specific configurations from settings
73     *
74     * @return void
75     */
76    private function loadRouteConfigs(): void
77    {
78        // Define the route patterns and their corresponding setting keys
79        $routeSettings = [
80            '/admin/*' => 'admin',
81            '/users/login' => 'login',
82            '/users/reset-password/*' => 'passwordReset',
83            '/users/forgot-password' => 'passwordReset',
84            '/users/confirm-email' => 'passwordReset',
85            '/users/register' => 'register',
86        ];
87
88        foreach ($routeSettings as $route => $settingKey) {
89            $limit = SettingsManager::read("RateLimit.{$settingKey}NumberOfRequests", null);
90            $period = SettingsManager::read("RateLimit.{$settingKey}NumberOfSeconds", null);
91
92            // Only add to configs if both settings exist
93            if ($limit !== null && $period !== null) {
94                $this->routeConfigs[$route] = [
95                    'limit' => (int)$limit,
96                    'period' => (int)$period,
97                ];
98            }
99        }
100    }
101
102    /**
103     * Process a server request and return a response.
104     *
105     * @param \Psr\Http\Message\ServerRequestInterface $request The server request.
106     * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler.
107     * @return \Psr\Http\Message\ResponseInterface The response.
108     * @throws \App\Http\Exception\TooManyRequestsException If the rate limit is exceeded.
109     */
110    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
111    {
112        // Skip if rate limiting is disabled
113        if (!$this->enabled) {
114            return $handler->handle($request);
115        }
116
117        // Set trust proxy attribute if configured
118        if (SettingsManager::read('Security.trustProxy', false)) {
119            $request = $request->withAttribute('trustProxy', true);
120        }
121
122        // Get client IP (will be set by IpBlockerMiddleware if it runs first)
123        $ip = $request->getAttribute('clientIp') ?? $request->clientIp();
124
125        if (!$ip) {
126            // If we can't determine IP, let it through (IpBlockerMiddleware will handle this)
127            return $handler->handle($request);
128        }
129
130        $route = $request->getUri()->getPath();
131        $config = $this->getRouteConfig($route);
132
133        if ($config !== null) {
134            $key = 'rate_limit_' . md5($ip . '_' . $route);
135            $rateData = $this->updateRateLimit($key, $config['period']);
136
137            if ($rateData['count'] > $config['limit']) {
138                $this->logViolation($ip, $route, $request->getUri()->getQuery(), $rateData, $config);
139
140                throw new TooManyRequestsException(
141                    __('Too many requests. Please try again later.'),
142                    null,
143                    $config['period'],
144                );
145            }
146        }
147
148        return $handler->handle($request);
149    }
150
151    /**
152     * Get configuration for a specific route
153     *
154     * @param string $route The route to check
155     * @return array{limit: int, period: int}|null Configuration or null if using defaults
156     */
157    private function getRouteConfig(string $route): ?array
158    {
159        // First check for exact match
160        if (isset($this->routeConfigs[$route])) {
161            return $this->routeConfigs[$route];
162        }
163
164        // Then check for wildcard matches
165        foreach ($this->routeConfigs as $pattern => $config) {
166            if (str_contains($pattern, '*')) {
167                // Handle wildcard routes
168                $escapedPattern = preg_quote($pattern, '#');
169                $escapedPattern = str_replace('\*', '.*', $escapedPattern);
170
171                // Allow optional language prefix
172                $fullPattern = '#^(/[a-z]{2})?' . $escapedPattern . '$#';
173
174                if (preg_match($fullPattern, $route)) {
175                    return $config;
176                }
177            } else {
178                // Allow optional language prefix for exact matches
179                $fullPattern = '#^(/[a-z]{2})?' . preg_quote($pattern, '#') . '$#';
180                if (preg_match($fullPattern, $route)) {
181                    return $config;
182                }
183            }
184        }
185
186        // Return default configuration for all other routes
187        return [
188            'limit' => $this->defaultLimit,
189            'period' => $this->defaultPeriod,
190        ];
191    }
192
193    /**
194     * Update rate limit data for a given key
195     *
196     * @param string $key Cache key
197     * @param int $period Time period
198     * @return array{count: int, start_time: int} Rate limit data
199     */
200    private function updateRateLimit(string $key, int $period): array
201    {
202        $rateData = Cache::read($key, 'rate_limit') ?: ['count' => 0, 'start_time' => time()];
203
204        $currentTime = time();
205        if ($currentTime - $rateData['start_time'] > $period) {
206            $rateData = ['count' => 1, 'start_time' => $currentTime];
207        } else {
208            $rateData['count']++;
209        }
210
211        Cache::write($key, $rateData, 'rate_limit');
212
213        return $rateData;
214    }
215
216    /**
217     * Log rate limit violations
218     *
219     * @param string $ip IP address
220     * @param string $route Request route
221     * @param string $query Query string
222     * @param array{count: int, start_time: int} $rateData Rate limit data
223     * @param array{limit: int, period: int} $config Route configuration
224     * @return void
225     */
226    private function logViolation(string $ip, string $route, string $query, array $rateData, array $config): void
227    {
228        Log::warning(__('Rate limit exceeded for IP: {0}', [$ip]), [
229            'ip' => $ip,
230            'route' => $route,
231            'query' => $query,
232            'count' => $rateData['count'],
233            'limit' => $config['limit'],
234            'period' => $config['period'],
235            'group_name' => 'rate_limiting',
236        ]);
237    }
238}