Passed
Push — master ( 91e81d...94d9d8 )
by MusikAnimal
07:34
created

RateLimitSubscriber::onKernelController()   B

Complexity

Conditions 8
Paths 6

Size

Total Lines 49
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 32.6218

Importance

Changes 0
Metric Value
cc 8
eloc 22
nc 6
nop 1
dl 0
loc 49
ccs 6
cts 22
cp 0.2727
crap 32.6218
rs 8.4444
c 0
b 0
f 0
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\HttpKernel\Event\FilterControllerEvent;
15
use Symfony\Component\HttpKernel\Exception\HttpException;
16
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
17
use Symfony\Component\HttpKernel\KernelEvents;
18
19
/**
20
 * A RateLimitSubscriber checks to see if users are exceeding usage limitations.
21
 */
22
class RateLimitSubscriber implements EventSubscriberInterface
23
{
24
25
    /** @var ContainerInterface The DI container. */
26
    protected $container;
27
28
    /** @var I18nHelper For i18n and l10n. */
29
    protected $i18n;
30
31
    /** @var int Number of requests allowed in time period */
32
    protected $rateLimit;
33
34
    /** @var int Number of minutes during which $rateLimit requests are permitted */
35
    protected $rateDuration;
36
37
    /**
38
     * Save the container for later use.
39
     * @param ContainerInterface $container The DI container.
40
     * @param I18nHelper $i18n
41
     */
42 15
    public function __construct(ContainerInterface $container, I18nHelper $i18n)
43
    {
44 15
        $this->container = $container;
45 15
        $this->i18n = $i18n;
46 15
    }
47
48
    /**
49
     * Register our interest in the kernel.controller event.
50
     * @return string[]
51
     */
52 1
    public static function getSubscribedEvents(): array
53
    {
54
        return [
55 1
            KernelEvents::CONTROLLER => 'onKernelController',
56
        ];
57
    }
58
59
    /**
60
     * Check if the current user has exceeded the configured usage limitations.
61
     * @param FilterControllerEvent $event The event.
62
     * @throws TooManyRequestsHttpException|\Exception If rate limits have been exceeded.
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
        // Zero values indicate the rate limiting feature should be disabled.
71 15
        if (0 === $this->rateLimit || 0 === $this->rateDuration) {
72 15
            return;
73
        }
74
75
        $controller = $event->getController();
76
        $loggedIn = (bool) $this->container->get('session')->get('logged_in_user');
77
78
        /**
79
         * Rate limiting will not apply to these actions
80
         * @var array
81
         */
82
        $actionWhitelist = [
83
            'indexAction', 'showAction', 'aboutAction', 'recordUsage', 'loginAction', 'oauthCallbackAction',
84
        ];
85
86
        // No rate limits on lightweight pages, logged in users, or subrequests.
87
        if (in_array($controller[1], $actionWhitelist) || $loggedIn || false === $event->isMasterRequest()) {
88
            return;
89
        }
90
91
        $this->checkBlacklist($request);
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($request, '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 \Symfony\Component\HttpFoundation\Request $request
118
     */
119
    private function checkBlacklist(\Symfony\Component\HttpFoundation\Request $request): void
120
    {
121
        // First check user agent and URI blacklists
122
        if ($this->container->hasParameter('request_blacklist')) {
123
            $blacklist = $this->container->getParameter('request_blacklist');
124
            // User agents
125
            if (is_array($blacklist['user_agent'])) {
126
                foreach ($blacklist['user_agent'] as $ua) {
127
                    if (false !== strpos((string)$request->headers->get('User-Agent'), $ua)) {
128
                        $logComment = "Matched blacklisted user agent `$ua`";
129
130
                        // Log the denied request
131
                        $logger = $this->container->get('monolog.logger.rate_limit');
132
                        $logger->info(
133
                            "<URI>: " . $request->getRequestUri() .
134
                            ('' != $logComment ? "\t<Reason>: $logComment" : '') .
135
                            "\t<User agent>: " . $request->headers->get('User-Agent')
136
                        );
137
138
                        throw new HttpException(
139
                            403,
140
                            'Your access to XTools has been revoked due to possible abuse. '.
141
                            'Please contact [email protected]'
142
                        );
143
                    }
144
                }
145
            }
146
            // URIs
147
            if (is_array($blacklist['uri'])) {
148
                foreach ($blacklist['uri'] as $uri) {
149
                    if (false !== strpos($request->getRequestUri(), $uri)) {
150
                        $this->denyAccess($request, "Matched blacklisted URI `$uri`");
151
                    }
152
                }
153
            }
154
        }
155
    }
156
157
    /**
158
     * Throw exception for denied access due to spider crawl or hitting usage limits.
159
     * @param \Symfony\Component\HttpFoundation\Request $request
160
     * @param string $logComment Comment to include with the log entry.
161
     */
162
    private function denyAccess(\Symfony\Component\HttpFoundation\Request $request, string $logComment = ''): void
163
    {
164
        // Log the denied request
165
        $logger = $this->container->get('monolog.logger.rate_limit');
166
        $logger->info(
167
            "<URI>: " . $request->getRequestUri() .
168
            ('' != $logComment ? "\t<Reason>: $logComment" : '') .
169
            "\t<User agent>: " . $request->headers->get('User-Agent')
170
        );
171
172
        $message = $this->i18n->msg('error-rate-limit', [
173
            $this->rateDuration,
174
            "<a href='/login'>".$this->i18n->msg('error-rate-limit-login')."</a>",
175
            "<a href='https://xtools.readthedocs.io/en/stable/api' target='_blank'>" .
176
                $this->i18n->msg('api') .
177
            "</a>",
178
        ]);
179
180
        /**
181
         * TODO: Find a better way to do this.
182
         * 999 is a random, complete hack to tell error.html.twig file to treat these exceptions as having
183
         * fully safe messages that can be display with |raw. (In this case we authored the message).
184
         */
185
        throw new TooManyRequestsHttpException(600, $message, null, 999);
186
    }
187
}
188