Passed
Push — master ( 38d4dd...d3179a )
by MusikAnimal
06:43
created

RateLimitSubscriber::temporaryBlacklisting()   B

Complexity

Conditions 7
Paths 16

Size

Total Lines 44
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 8.6267

Importance

Changes 0
Metric Value
cc 7
eloc 28
nc 16
nop 1
dl 0
loc 44
ccs 19
cts 28
cp 0.6786
crap 8.6267
rs 8.5386
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\HttpFoundation\Request;
15
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
16
use Symfony\Component\HttpKernel\Exception\HttpException;
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
     * @throws TooManyRequestsHttpException|\Exception If rate limits have been exceeded.
64
     */
65 15
    public function onKernelController(FilterControllerEvent $event): void
66
    {
67 15
        $this->rateLimit = (int) $this->container->getParameter('app.rate_limit_count');
68 15
        $this->rateDuration = (int) $this->container->getParameter('app.rate_limit_time');
69 15
        $request = $event->getRequest();
70
71 15
        $this->temporaryBlacklisting($request);
72
73
        // Zero values indicate the rate limiting feature should be disabled.
74 15
        if (0 === $this->rateLimit || 0 === $this->rateDuration) {
75 15
            return;
76
        }
77
78
        $controller = $event->getController();
79
        $loggedIn = (bool) $this->container->get('session')->get('logged_in_user');
80
81
        /**
82
         * Rate limiting will not apply to these actions
83
         * @var array
84
         */
85
        $actionWhitelist = [
86
            'indexAction', 'showAction', 'aboutAction', 'recordUsage', 'loginAction', 'oauthCallbackAction',
87
        ];
88
89
        // No rate limits on lightweight pages, logged in users, or subrequests.
90
        if (in_array($controller[1], $actionWhitelist) || $loggedIn || false === $event->isMasterRequest()) {
91
            return;
92
        }
93
94
        $this->checkBlacklist($request);
95
96
        // Build and fetch cache key based on session ID.
97
        $sessionId = $request->getSession()->getId();
98
        $cacheKey = 'ratelimit.'.$sessionId;
99
100
        /** @var \Symfony\Component\Cache\Adapter\TraceableAdapter $cache */
101
        $cache = $this->container->get('cache.app');
102
        $cacheItem = $cache->getItem($cacheKey);
103
104
        // If increment value already in cache, or start with 1.
105
        $count = $cacheItem->isHit() ? (int) $cacheItem->get() + 1 : 1;
106
107
        // Check if limit has been exceeded, and if so, throw an error.
108
        if ($count > $this->rateLimit) {
109
            $this->denyAccess($request, 'Exceeded rate limitation');
110
        }
111
112
        // Reset the clock on every request.
113
        $cacheItem->set($count)
114
            ->expiresAfter(new DateInterval('PT'.$this->rateDuration.'M'));
115
        $cache->save($cacheItem);
116
    }
117
118
    /**
119
     * Check the request against blacklisted URIs and user agents
120
     * @param \Symfony\Component\HttpFoundation\Request $request
121
     */
122
    private function checkBlacklist(\Symfony\Component\HttpFoundation\Request $request): void
123
    {
124
        // First check user agent and URI blacklists
125
        if ($this->container->hasParameter('request_blacklist')) {
126
            $blacklist = $this->container->getParameter('request_blacklist');
127
            // User agents
128
            if (is_array($blacklist['user_agent'])) {
129
                foreach ($blacklist['user_agent'] as $ua) {
130
                    if (false !== strpos($request->headers->get('User-Agent'), $ua)) {
131
                        $logComment = "Matched blacklisted user agent `$ua`";
132
133
                        // Log the denied request
134
                        $logger = $this->container->get('monolog.logger.rate_limit');
135
                        $logger->info(
136
                            "<URI>: " . $request->getRequestUri() .
137
                            ('' != $logComment ? "\t<Reason>: $logComment" : '') .
138
                            "\t<User agent>: " . $request->headers->get('User-Agent')
139
                        );
140
141
                        throw new HttpException(
142
                            403,
143
                            'Your access to XTools has been revoked due to possible abuse. '.
144
                            'Please contact [email protected]'
145
                        );
146
                    }
147
                }
148
            }
149
            // URIs
150
            if (is_array($blacklist['uri'])) {
151
                foreach ($blacklist['uri'] as $uri) {
152
                    if (false !== strpos($request->getRequestUri(), $uri)) {
153
                        $this->denyAccess($request, "Matched blacklisted URI `$uri`");
154
                    }
155
                }
156
            }
157
        }
158
    }
159
160
    /**
161
     * Temporarily deny access based on some heuristics in order to stop a wave of disruptive traffic.
162
     * @see https://phabricator.wikimedia.org/T211709
163
     * @param Request $request
164
     * @throws HttpException
165
     */
166 15
    private function temporaryBlacklisting(Request $request): void
167
    {
168 15
        $uaMatch = 1 === preg_match(
169 15
            '/iPhone|Pixel 2|Nexus 5|SM\-G900P/',
170 15
            (string)$request->headers->get('User-Agent')
171
        );
172 15
        $reqMatch = 1 === preg_match(
173 15
            '/(articleinfo(?:\-authorship)?|topedits)\/en\.wikipedia\.org.*?\?uselang\=(?!en)/',
174 15
            (string)$request->getUri()
175
        );
176
177 15
        $passed = false;
178
179 15
        if (true === $uaMatch && true === $reqMatch) {
180
            $passed = true;
181
        }
182
183 15
        $uaMatch = 1 === preg_match(
184 15
            '/Pixel 2 Build\/OPD3\.170816\.012|Nexus 5 Build\/MRA58N|SM\-G900P Build\/LRX21T/',
185 15
            (string)$request->headers->get('User-Agent')
186
        );
187 15
        $reqMatch = 1 === preg_match(
188 15
            '/(articleinfo(?:\-authorship)?|topedits)\/en\.wikipedia\.org/',
189 15
            (string)$request->getUri()
190 15
        ) && false === strpos($request->getUri(), '?uselang=');
191
192 15
        if (true === $uaMatch && true === $reqMatch) {
193
            $passed = true;
194
        }
195
196 15
        if (false === $passed) {
197 15
            return;
198
        }
199
200
        $logger = $this->container->get('monolog.logger.rate_limit');
201
        $logger->info(
202
            "<URI>: ".$request->getRequestUri().' TEMPORARY BLACKLISTING'.
203
            "\t<User agent>: " . $request->headers->get('User-Agent')
204
        );
205
206
        throw new HttpException(
207
            429,
208
            'Your access to XTools has been revoked due to possible abuse. '.
209
                'Please contact [email protected]'
210
        );
211
    }
212
213
    /**
214
     * Throw exception for denied access due to spider crawl or hitting usage limits.
215
     * @param \Symfony\Component\HttpFoundation\Request $request
216
     * @param string $logComment Comment to include with the log entry.
217
     */
218
    private function denyAccess(\Symfony\Component\HttpFoundation\Request $request, string $logComment = ''): void
219
    {
220
        // Log the denied request
221
        $logger = $this->container->get('monolog.logger.rate_limit');
222
        $logger->info(
223
            "<URI>: " . $request->getRequestUri() .
224
            ('' != $logComment ? "\t<Reason>: $logComment" : '') .
225
            "\t<User agent>: " . $request->headers->get('User-Agent')
226
        );
227
228
        $message = $this->i18n->msg('error-rate-limit', [
229
            $this->rateDuration,
230
            "<a href='/login'>".$this->i18n->msg('error-rate-limit-login')."</a>",
231
            "<a href='https://xtools.readthedocs.io/en/stable/api' target='_blank'>" .
232
                $this->i18n->msg('api') .
233
            "</a>",
234
        ]);
235
236
        /**
237
         * TODO: Find a better way to do this.
238
         * 999 is a random, complete hack to tell error.html.twig file to treat these exceptions as having
239
         * fully safe messages that can be display with |raw. (In this case we authored the message).
240
         */
241
        throw new TooManyRequestsHttpException(600, $message, null, 999);
242
    }
243
}
244