Passed
Push — master ( 20eb30...44ae05 )
by MusikAnimal
05:34
created

RateLimitSubscriber::checkBlacklist()   B

Complexity

Conditions 10
Paths 130

Size

Total Lines 36
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 75.7595

Importance

Changes 0
Metric Value
cc 10
eloc 22
nc 130
nop 1
dl 0
loc 36
ccs 3
cts 23
cp 0.1303
crap 75.7595
rs 7.4166
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * This file contains only the RateLimitSubscriber class.
4
 */
5
6
declare(strict_types = 1);
7
8
namespace AppBundle\EventSubscriber;
9
10
use AppBundle\Helper\I18nHelper;
11
use DateInterval;
12
use Symfony\Component\DependencyInjection\ContainerInterface;
13
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
14
use Symfony\Component\HttpFoundation\Request;
15
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
16
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
17
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
18
use Symfony\Component\HttpKernel\KernelEvents;
19
20
/**
21
 * A RateLimitSubscriber checks to see if users are exceeding usage limitations.
22
 */
23
class RateLimitSubscriber implements EventSubscriberInterface
24
{
25
26
    /** @var ContainerInterface The DI container. */
27
    protected $container;
28
29
    /** @var I18nHelper For i18n and l10n. */
30
    protected $i18n;
31
32
    /** @var int Number of requests allowed in time period */
33
    protected $rateLimit;
34
35
    /** @var int Number of minutes during which $rateLimit requests are permitted */
36
    protected $rateDuration;
37
38
    /**
39
     * Save the container for later use.
40
     * @param ContainerInterface $container The DI container.
41
     * @param I18nHelper $i18n
42
     */
43 15
    public function __construct(ContainerInterface $container, I18nHelper $i18n)
44
    {
45 15
        $this->container = $container;
46 15
        $this->i18n = $i18n;
47 15
    }
48
49
    /**
50
     * Register our interest in the kernel.controller event.
51
     * @return string[]
52
     */
53 1
    public static function getSubscribedEvents(): array
54
    {
55
        return [
56 1
            KernelEvents::CONTROLLER => 'onKernelController',
57
        ];
58
    }
59
60
    /**
61
     * Check if the current user has exceeded the configured usage limitations.
62
     * @param FilterControllerEvent $event The event.
63
     */
64 15
    public function onKernelController(FilterControllerEvent $event): void
65
    {
66 15
        $this->rateLimit = (int) $this->container->getParameter('app.rate_limit_count');
67 15
        $this->rateDuration = (int) $this->container->getParameter('app.rate_limit_time');
68 15
        $request = $event->getRequest();
69
70 15
        $this->checkBlacklist($request);
71
72
        // Zero values indicate the rate limiting feature should be disabled.
73 15
        if (0 === $this->rateLimit || 0 === $this->rateDuration) {
74 15
            return;
75
        }
76
77
        $controller = $event->getController();
78
        $loggedIn = (bool) $this->container->get('session')->get('logged_in_user');
79
80
        /**
81
         * Rate limiting will not apply to these actions
82
         * @var array
83
         */
84
        $actionWhitelist = [
85
            'indexAction', 'showAction', 'aboutAction', 'recordUsage', 'loginAction', 'oauthCallbackAction',
86
        ];
87
88
        // No rate limits on lightweight pages, logged in users, or subrequests.
89
        if (in_array($controller[1], $actionWhitelist) || $loggedIn || false === $event->isMasterRequest()) {
90
            return;
91
        }
92
93
        // Build and fetch cache key based on session ID.
94
        $sessionId = $request->getSession()->getId();
95
        $cacheKey = 'ratelimit.'.$sessionId;
96
97
        /** @var \Symfony\Component\Cache\Adapter\TraceableAdapter $cache */
98
        $cache = $this->container->get('cache.app');
99
        $cacheItem = $cache->getItem($cacheKey);
100
101
        // If increment value already in cache, or start with 1.
102
        $count = $cacheItem->isHit() ? (int) $cacheItem->get() + 1 : 1;
103
104
        // Check if limit has been exceeded, and if so, throw an error.
105
        if ($count > $this->rateLimit) {
106
            $this->denyAccess('Exceeded rate limitation');
107
        }
108
109
        // Reset the clock on every request.
110
        $cacheItem->set($count)
111
            ->expiresAfter(new DateInterval('PT'.$this->rateDuration.'M'));
112
        $cache->save($cacheItem);
113
    }
114
115
    /**
116
     * Check the request against blacklisted URIs and user agents
117
     * @param Request $request
118
     */
119 15
    private function checkBlacklist(Request $request): void
120
    {
121
        // First check user agent and URI blacklists
122 15
        if (!$this->container->hasParameter('request_blacklist')) {
123 15
            return;
124
        }
125
126
        $blacklist = $this->container->getParameter('request_blacklist');
127
        $ua = (string)$request->headers->get('User-Agent');
128
        $referer = (string)$request->headers->get('referer');
129
        $uri = $request->getRequestUri();
130
131
        foreach ($blacklist as $name => $item) {
132
            $match = false;
133
134
            if (isset($item['user_agent'])) {
135
                $match = $item['user_agent'] === $ua;
136
            }
137
            if (isset($item['user_agent_pattern'])) {
138
                $match = 1 === preg_match('/'.$item['user_agent_pattern'].'/', $ua);
139
            }
140
            if (isset($item['referer'])) {
141
                $match = $item['referer'] === $referer;
142
            }
143
            if (isset($item['referer_pattern'])) {
144
                $match = 1 === preg_match('/'.$item['referer_pattern'].'/', $referer);
145
            }
146
            if (isset($item['uri'])) {
147
                $match = $item['uri'] === $uri;
148
            }
149
            if (isset($item['uri_pattern'])) {
150
                $match = 1 === preg_match('/'.$item['uri_pattern'].'/', $uri);
151
            }
152
153
            if ($match) {
154
                $this->denyAccess("Matched blacklist entry `$name`", true);
155
            }
156
        }
157
    }
158
159
    /**
160
     * Throw exception for denied access due to spider crawl or hitting usage limits.
161
     * @param string $logComment Comment to include with the log entry.
162
     * @param bool $blacklist Changes the messaging to say access was denied due to abuse, rather than rate limiting.
163
     * @throws TooManyRequestsHttpException
164
     * @throws AccessDeniedHttpException
165
     */
166
    private function denyAccess(string $logComment, bool $blacklist = false): void
167
    {
168
        // Log the denied request
169
        $logger = $this->container->get($blacklist ? 'monolog.logger.blacklist' : 'monolog.logger.rate_limit');
170
        $logger->info($logComment);
171
172
        if ($blacklist) {
173
            $message = $this->i18n->msg('error-denied', ['[email protected]']);
174
            throw new AccessDeniedHttpException($message, null, 999);
175
        }
176
177
        $message = $this->i18n->msg('error-rate-limit', [
178
            $this->rateDuration,
179
            "<a href='/login'>".$this->i18n->msg('error-rate-limit-login')."</a>",
180
            "<a href='https://xtools.readthedocs.io/en/stable/api' target='_blank'>" .
181
                $this->i18n->msg('api') .
182
            "</a>",
183
        ]);
184
185
        /**
186
         * TODO: Find a better way to do this.
187
         * 999 is a random, complete hack to tell error.html.twig file to treat these exceptions as having
188
         * fully safe messages that can be display with |raw. (In this case we authored the message).
189
         */
190
        throw new TooManyRequestsHttpException(600, $message, null, 999);
191
    }
192
}
193