Passed
Push — master ( b35518...117ae0 )
by MusikAnimal
09:07
created

RateLimitSubscriber   A

Complexity

Total Complexity 22

Size/Duplication

Total Lines 161
Duplicated Lines 0 %

Test Coverage

Coverage 59.01%

Importance

Changes 0
Metric Value
eloc 58
dl 0
loc 161
ccs 36
cts 61
cp 0.5901
rs 10
c 0
b 0
f 0
wmc 22

6 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
A getSubscribedEvents() 0 4 1
A denyAccess() 0 11 2
B checkBlacklist() 0 18 8
B onKernelController() 0 49 7
A temporaryBlacklisting() 0 19 3
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 DateInterval;
11
use Symfony\Component\DependencyInjection\ContainerInterface;
12
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
13
use Symfony\Component\HttpFoundation\Request;
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 int Number of requests allowed in time period */
29
    protected $rateLimit;
30
31
    /** @var int Number of minutes during which $rateLimit requests are permitted */
32
    protected $rateDuration;
33
34
    /**
35
     * Save the container for later use.
36
     * @param ContainerInterface $container The DI container.
37
     */
38 15
    public function __construct(ContainerInterface $container)
39
    {
40 15
        $this->container = $container;
41 15
    }
42
43
    /**
44
     * Register our interest in the kernel.controller event.
45
     * @return string[]
46
     */
47 1
    public static function getSubscribedEvents(): array
48
    {
49
        return [
50 1
            KernelEvents::CONTROLLER => 'onKernelController',
51
        ];
52
    }
53
54
    /**
55
     * Check if the current user has exceeded the configured usage limitations.
56
     * @param FilterControllerEvent $event The event.
57
     * @throws TooManyRequestsHttpException|\Exception If rate limits have been exceeded.
58
     */
59 15
    public function onKernelController(FilterControllerEvent $event): void
60
    {
61 15
        $this->rateLimit = (int) $this->container->getParameter('app.rate_limit_count');
62 15
        $this->rateDuration = (int) $this->container->getParameter('app.rate_limit_time');
63 15
        $request = $event->getRequest();
64
65 15
        $this->temporaryBlacklisting($request);
66
67
        // Zero values indicate the rate limiting feature should be disabled.
68 15
        if (0 === $this->rateLimit || 0 === $this->rateDuration) {
69
            return;
70
        }
71
72 15
        $controller = $event->getController();
73 15
        $loggedIn = (bool) $this->container->get('session')->get('logged_in_user');
74
75
        /**
76
         * Rate limiting will not apply to these actions
77
         * @var array
78
         */
79 15
        $actionWhitelist = ['indexAction', 'showAction', 'aboutAction', 'recordUsage'];
80
81
        // No rate limits on lightweight pages or if they are logged in.
82 15
        if (in_array($controller[1], $actionWhitelist) || $loggedIn) {
83 11
            return;
84
        }
85
86 5
        $this->checkBlacklist($request);
87
88
        // Build and fetch cache key based on session ID and requested URI,
89
        //   removing any reserved characters from URI.
90 5
        $uri = preg_replace('/[^a-zA-Z0-9,\.]/', '', $request->getRequestUri());
91 5
        $sessionId = $request->getSession()->getId();
92 5
        $cacheKey = 'ratelimit.'.$sessionId.'.'.$uri;
93 5
        $cache = $this->container->get('cache.app');
94 5
        $cacheItem = $cache->getItem($cacheKey);
95
96
        // If increment value already in cache, or start with 1.
97 5
        $count = $cacheItem->isHit() ? (int) $cacheItem->get() + 1 : 1;
98
99
        // Check if limit has been exceeded, and if so, throw an error.
100 5
        if ($count > $this->rateLimit) {
101
            $this->denyAccess($request, 'Exceeded rate limitation');
102
        }
103
104
        // Reset the clock on every request.
105 5
        $cacheItem->set($count)
106 5
            ->expiresAfter(new DateInterval('PT'.$this->rateDuration.'M'));
107 5
        $cache->save($cacheItem);
108 5
    }
109
110
    /**
111
     * Check the request against blacklisted URIs and user agents
112
     * @param \Symfony\Component\HttpFoundation\Request $request
113
     */
114 5
    private function checkBlacklist(\Symfony\Component\HttpFoundation\Request $request): void
115
    {
116
        // First check user agent and URI blacklists
117 5
        if ($this->container->hasParameter('request_blacklist')) {
118
            $blacklist = $this->container->getParameter('request_blacklist');
119
            // User agents
120
            if (is_array($blacklist['user_agent'])) {
121
                foreach ($blacklist['user_agent'] as $ua) {
122
                    if (false !== strpos($request->headers->get('User-Agent'), $ua)) {
123
                        $this->denyAccess($request, "Matched blacklisted user agent `$ua`");
124
                    }
125
                }
126
            }
127
            // URIs
128
            if (is_array($blacklist['uri'])) {
129
                foreach ($blacklist['uri'] as $uri) {
130
                    if (false !== strpos($request->getRequestUri(), $uri)) {
131
                        $this->denyAccess($request, "Matched blacklisted URI `$uri`");
132
                    }
133
                }
134
            }
135
        }
136 5
    }
137
138
    /**
139
     * Temporarily deny access based on some heuristics in order to stop a wave of disruptive traffic.
140
     * @see https://phabricator.wikimedia.org/T211709
141
     * @param Request $request
142
     * @throws HttpException
143
     */
144 15
    private function temporaryBlacklisting(Request $request): void
145
    {
146 15
        $uaMatch = 1 === preg_match('/iPhone|Pixel 2|Nexus 5|SM\-G900P/', $request->headers->get('User-Agent'));
147 15
        $reqMatch = 1 === preg_match('/articleinfo\/en\.wikipedia\.org.*?\?uselang\=(?!en)/', $request->getUri());
148
149 15
        if (false === $uaMatch || false === $reqMatch) {
150 15
            return;
151
        }
152
153
        $logger = $this->container->get('monolog.logger.rate_limit');
154
        $logger->info(
155
            "<URI>: ".$request->getRequestUri().' TEMPORARY BLACKLISTING'.
156
            "\t<User agent>: " . $request->headers->get('User-Agent')
157
        );
158
159
        throw new HttpException(
160
            429,
161
            'Your access to XTools has been revoked due to possible abuse. '.
162
                'Please contact [email protected]'
163
        );
164
    }
165
166
    /**
167
     * Throw exception for denied access due to spider crawl or hitting usage limits.
168
     * @param \Symfony\Component\HttpFoundation\Request $request
169
     * @param string $logComment Comment to include with the log entry.
170
     * @throws TooManyRequestsHttpException
171
     */
172
    private function denyAccess(\Symfony\Component\HttpFoundation\Request $request, string $logComment = ''): void
173
    {
174
        // Log the denied request
175
        $logger = $this->container->get('monolog.logger.rate_limit');
176
        $logger->info(
177
            "<URI>: " . $request->getRequestUri() .
178
            ('' != $logComment ? "\t<Reason>: $logComment" : '') .
179
            "\t<User agent>: " . $request->headers->get('User-Agent')
180
        );
181
182
        throw new TooManyRequestsHttpException(600, 'error-rate-limit', null, $this->rateDuration);
183
    }
184
}
185