Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RateLimiterManager
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 8
1980
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isEnabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 check
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
156
 getPolicyIdForEvent
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
156
 eventMatchesFilter
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 verboseDebug
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getTooManyRequestsResponseMessage
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 isCrawlerRequest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
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
30namespace VuFind\RateLimiter;
31
32use Closure;
33use Laminas\EventManager\EventInterface;
34use Laminas\Log\LoggerAwareInterface;
35use Laminas\Mvc\MvcEvent;
36use VuFind\I18n\Translator\TranslatorAwareInterface;
37use VuFind\I18n\Translator\TranslatorAwareTrait;
38use VuFind\Log\LoggerAwareTrait;
39use VuFind\Net\IpAddressUtils;
40
41use function in_array;
42use 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 */
53class 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}