Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 113 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
RateLimiterManager | |
0.00% |
0 / 113 |
|
0.00% |
0 / 8 |
1980 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
isEnabled | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
check | |
0.00% |
0 / 57 |
|
0.00% |
0 / 1 |
156 | |||
getPolicyIdForEvent | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
156 | |||
eventMatchesFilter | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
72 | |||
verboseDebug | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
getTooManyRequestsResponseMessage | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
isCrawlerRequest | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | /** |
4 | * Rate limiter manager. |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) The National Library of Finland 2024. |
9 | * |
10 | * This program is free software; you can redistribute it and/or modify |
11 | * it under the terms of the GNU General Public License version 2, |
12 | * as published by the Free Software Foundation. |
13 | * |
14 | * This program is distributed in the hope that it will be useful, |
15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
17 | * GNU General Public License for more details. |
18 | * |
19 | * You should have received a copy of the GNU General Public License |
20 | * along with this program; if not, write to the Free Software |
21 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
22 | * |
23 | * @category VuFind |
24 | * @package Cache |
25 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
26 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
27 | * @link https://vufind.org Main Page |
28 | */ |
29 | |
30 | namespace VuFind\RateLimiter; |
31 | |
32 | use Closure; |
33 | use Laminas\EventManager\EventInterface; |
34 | use Laminas\Log\LoggerAwareInterface; |
35 | use Laminas\Mvc\MvcEvent; |
36 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
37 | use VuFind\I18n\Translator\TranslatorAwareTrait; |
38 | use VuFind\Log\LoggerAwareTrait; |
39 | use VuFind\Net\IpAddressUtils; |
40 | |
41 | use function in_array; |
42 | use function is_bool; |
43 | |
44 | /** |
45 | * Rate limiter manager. |
46 | * |
47 | * @category VuFind |
48 | * @package Cache |
49 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
50 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
51 | * @link https://vufind.org Main Page |
52 | */ |
53 | class RateLimiterManager implements LoggerAwareInterface, TranslatorAwareInterface |
54 | { |
55 | use LoggerAwareTrait; |
56 | use TranslatorAwareTrait; |
57 | |
58 | /** |
59 | * Current event description for logging |
60 | * |
61 | * @var string |
62 | */ |
63 | protected $eventDesc = '??'; |
64 | |
65 | /** |
66 | * Client details for logging |
67 | * |
68 | * @var string |
69 | */ |
70 | protected $clientLogDetails; |
71 | |
72 | /** |
73 | * Constructor |
74 | * |
75 | * @param array $config Rate limiter configuration |
76 | * @param string $clientIp Client's IP address |
77 | * @param ?int $userId User ID or null if not logged in |
78 | * @param Closure $rateLimiterFactoryCallback Rate limiter factory callback |
79 | * @param IpAddressUtils $ipUtils IP address utilities |
80 | */ |
81 | public function __construct( |
82 | protected array $config, |
83 | protected string $clientIp, |
84 | protected ?int $userId, |
85 | protected Closure $rateLimiterFactoryCallback, |
86 | protected IpAddressUtils $ipUtils |
87 | ) { |
88 | $this->clientLogDetails = "ip:$clientIp"; |
89 | if (null !== $userId) { |
90 | $this->clientLogDetails .= " u:$userId"; |
91 | } |
92 | } |
93 | |
94 | /** |
95 | * Check if rate limiter is enabled |
96 | * |
97 | * @return bool|string False if disabled, true if enabled and enforcing, |
98 | * 'report_only' if enabled for logging only (not enforcing the limits) |
99 | */ |
100 | public function isEnabled(): bool|string |
101 | { |
102 | $mode = $this->config['General']['enabled'] ?? false; |
103 | return is_bool($mode) ? $mode : (string)$mode; |
104 | } |
105 | |
106 | /** |
107 | * Check if the given event is allowed |
108 | * |
109 | * @param EventInterface $event Event |
110 | * |
111 | * @return array Associative array with the following keys: |
112 | * bool allow Whether to allow the request |
113 | * ?int requestsRemaining Remaining requests |
114 | * ?int retryAfter Retry after seconds if limit exceeded |
115 | * ?int requestLimit Current limit |
116 | * ?string message Response message if limit reached |
117 | */ |
118 | public function check(EventInterface $event): array |
119 | { |
120 | $result = [ |
121 | 'allow' => true, |
122 | 'requestsRemaining' => null, |
123 | 'retryAfter' => null, |
124 | 'requestLimit' => null, |
125 | 'message' => null, |
126 | ]; |
127 | |
128 | if (!$this->isEnabled() || !($event instanceof MvcEvent)) { |
129 | return $result; |
130 | } |
131 | $routeMatch = $event->getRouteMatch(); |
132 | $controller = $routeMatch?->getParam('controller') ?? '??'; |
133 | $action = ($routeMatch?->getParam('action') ?? '??'); |
134 | $this->eventDesc = "$controller/$action"; |
135 | if ('AJAX' === $controller && 'JSON' === $action) { |
136 | $req = $event->getRequest(); |
137 | $method = $req->getPost('method') ?? $req->getQuery('method'); |
138 | $this->eventDesc .= " $method"; |
139 | } |
140 | try { |
141 | // Check for a matching policy: |
142 | if (!($policyId = $this->getPolicyIdForEvent($event))) { |
143 | $this->verboseDebug('No policy matches event'); |
144 | return $result; |
145 | } |
146 | // We have a policy matching the route, so check rate limiter: |
147 | $limiter = ($this->rateLimiterFactoryCallback)($this->config, $policyId, $this->clientIp, $this->userId); |
148 | $limit = $limiter->consume(1); |
149 | $result = [ |
150 | 'allow' => true, |
151 | 'requestsRemaining' => $limit->getRemainingTokens(), |
152 | 'retryAfter' => $limit->getRetryAfter()->getTimestamp() - time(), |
153 | 'requestLimit' => $limit->getLimit(), |
154 | ]; |
155 | $this->verboseDebug( |
156 | ($limit->isAccepted() ? 'Accepted' : 'Refused') |
157 | . " by policy '$policyId'" |
158 | . ', remaining: ' . $result['requestsRemaining'] |
159 | . ', retry-after: ' . $result['retryAfter'] |
160 | . ', limit: ' . $result['requestLimit'] |
161 | ); |
162 | |
163 | // Add headers if configured: |
164 | if ($this->config['Policies'][$policyId]['addHeaders'] ?? false) { |
165 | $headers = $event->getResponse()->getHeaders(); |
166 | $headers->addHeaders( |
167 | [ |
168 | 'X-RateLimit-Remaining' => $result['requestsRemaining'], |
169 | 'X-RateLimit-Retry-After' => $result['retryAfter'], |
170 | 'X-RateLimit-Limit' => $result['requestLimit'], |
171 | ] |
172 | ); |
173 | } |
174 | if ($limit->isAccepted()) { |
175 | return $result; |
176 | } |
177 | $logMsg = "$this->eventDesc: $this->clientLogDetails policy '$policyId' exceeded"; |
178 | if ('report_only' === $this->isEnabled() || ($this->config['Policies'][$policyId]['reportOnly'] ?? false)) { |
179 | $this->logWarning("$logMsg (not enforced)"); |
180 | return $result; |
181 | } |
182 | $this->logWarning("$logMsg (enforced)"); |
183 | $result['allow'] = false; |
184 | $result['message'] = $this->getTooManyRequestsResponseMessage($event, $result); |
185 | return $result; |
186 | } catch (\Exception $e) { |
187 | $this->logError((string)$e); |
188 | } |
189 | // Allow access on failure: |
190 | return $result; |
191 | } |
192 | |
193 | /** |
194 | * Try to find a policy that matches an event |
195 | * |
196 | * @param MvcEvent $event Event |
197 | * |
198 | * @return ?string policy id or null if no match |
199 | */ |
200 | protected function getPolicyIdForEvent(MvcEvent $event): ?string |
201 | { |
202 | $isCrawler = null; |
203 | foreach ($this->config['Policies'] ?? [] as $name => $settings) { |
204 | if (null !== ($loggedIn = $settings['loggedIn'] ?? null)) { |
205 | if ($loggedIn !== ($this->userId ? true : false)) { |
206 | continue; |
207 | } |
208 | } |
209 | if (null !== ($crawler = $settings['crawler'] ?? null)) { |
210 | $isCrawler ??= $this->isCrawlerRequest($event); |
211 | if ($crawler !== $isCrawler) { |
212 | continue; |
213 | } |
214 | } |
215 | if ($ipRanges = $settings['ipRanges'] ?? null) { |
216 | if (!$this->ipUtils->isInRange($this->clientIp, (array)$ipRanges)) { |
217 | continue; |
218 | } |
219 | } |
220 | |
221 | if (!($filters = $settings['filters'] ?? null)) { |
222 | return $name; |
223 | } |
224 | foreach ($filters as $filter) { |
225 | if ($this->eventMatchesFilter($event, $filter)) { |
226 | return $name; |
227 | } |
228 | } |
229 | } |
230 | return null; |
231 | } |
232 | |
233 | /** |
234 | * Check if an event matches a filter |
235 | * |
236 | * @param MvcEvent $event Event |
237 | * @param array $filter Filter from configuration |
238 | * |
239 | * @return bool |
240 | */ |
241 | protected function eventMatchesFilter(MvcEvent $event, array $filter): bool |
242 | { |
243 | $routeMatch = $event->getRouteMatch(); |
244 | foreach ($filter as $param => $value) { |
245 | if ('name' === $param) { |
246 | if ($routeMatch?->getMatchedRouteName() !== $value) { |
247 | return false; |
248 | } |
249 | } elseif (in_array($param, ['params', 'query', 'post'])) { |
250 | $req = $event->getRequest(); |
251 | $allParams = match ($param) { |
252 | 'query' => $req->getQuery()->toArray(), |
253 | 'post' => $req->getPost()->toArray(), |
254 | default => $req->getPost()->toArray() + $req->getQuery()->toArray(), |
255 | }; |
256 | foreach ($value as $key => $val) { |
257 | if ($val !== $allParams[$key] ?? null) { |
258 | return false; |
259 | } |
260 | } |
261 | } elseif ($routeMatch?->getParam($param) !== $value) { |
262 | return false; |
263 | } |
264 | } |
265 | return true; |
266 | } |
267 | |
268 | /** |
269 | * Log a verbose debug message if configured |
270 | * |
271 | * @param string $msg Message |
272 | * |
273 | * @return void |
274 | */ |
275 | protected function verboseDebug(string $msg): void |
276 | { |
277 | if ($this->config['General']['verbose'] ?? false) { |
278 | $this->log('debug', "$this->eventDesc [$this->clientLogDetails]: $msg", [], true); |
279 | } |
280 | } |
281 | |
282 | /** |
283 | * Get a response message for too many requests |
284 | * |
285 | * @param MvcEvent $event Request event |
286 | * @param array $result Rate limiter result |
287 | * |
288 | * @return string |
289 | */ |
290 | protected function getTooManyRequestsResponseMessage(MvcEvent $event, array $result): string |
291 | { |
292 | if ($result['retryAfter']) { |
293 | $msg = $this->translate('error_too_many_requests_retry_after', ['%%seconds%%' => $result['retryAfter']]); |
294 | } else { |
295 | $msg = $this->translate('error_too_many_requests'); |
296 | } |
297 | $routeMatch = $event->getRouteMatch(); |
298 | if ($routeMatch?->getParam('controller') === 'AJAX' && $routeMatch?->getParam('action') === 'JSON') { |
299 | return json_encode(['error' => $msg]); |
300 | } |
301 | return $msg; |
302 | } |
303 | |
304 | /** |
305 | * Check if the request is from a crawler |
306 | * |
307 | * @param MvcEvent $event Request event |
308 | * |
309 | * @return bool |
310 | */ |
311 | protected function isCrawlerRequest(MvcEvent $event): bool |
312 | { |
313 | $headers = $event->getRequest()->getHeaders(); |
314 | if (!$headers->has('User-Agent')) { |
315 | return false; |
316 | } |
317 | $agent = $headers->get('User-Agent')->getFieldValue(); |
318 | $crawlerDetect = new \Jaybizzle\CrawlerDetect\CrawlerDetect(); |
319 | return $crawlerDetect->isCrawler($agent); |
320 | } |
321 | } |