Completed
Pull Request — master (#62)
by MusikAnimal
03:26
created

RateLimitSubscriber::getSubscribedEvents()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 0
1
<?php
2
/**
3
 * This file contains only the RateLimitSubscriber class.
4
 */
5
6
namespace AppBundle\EventSubscriber;
7
8
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
9
use Symfony\Component\DependencyInjection\Container;
10
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
11
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
12
use Symfony\Component\HttpKernel\KernelEvents;
13
use Psr\Cache\CacheItemPoolInterface;
14
use Psr\Log\LoggerInterface;
15
use DateInterval;
16
17
/**
18
 * A RateLimitSubscriber checks to see if users are exceeding usage limitations.
19
 */
20
class RateLimitSubscriber implements EventSubscriberInterface
21
{
22
23
    /** @var Container The DI container. */
24
    protected $container;
25
26
    /** @var int Number of requests allowed in time period */
27
    protected $rateLimit;
28
29
    /** @var int Number of minutes during which $rateLimit requests are permitted */
30
    protected $rateDuration;
31
32
    /**
33
     * Save the container for later use.
34
     * @param Container $container The DI container.
35
     */
36
    public function __construct(Container $container)
37
    {
38
        $this->container = $container;
39
    }
40
41
    /**
42
     * Register our interest in the kernel.controller event.
43
     * @return string[]
44
     */
45
    public static function getSubscribedEvents()
46
    {
47
        return [
48
            KernelEvents::CONTROLLER => 'onKernelController',
49
        ];
50
    }
51
52
    /**
53
     * Check if the current user has exceeded the configured usage limitations.
54
     * @param FilterControllerEvent $event The event.
55
     * @throws AccessDeniedHttpException If rate limits have been exceeded.
56
     */
57
    public function onKernelController(FilterControllerEvent $event)
58
    {
59
        $this->rateLimit = (int) $this->container->getParameter('app.rate_limit_count');
60
        $this->rateDuration = (int) $this->container->getParameter('app.rate_limit_time');
61
62
        // Zero values indicate the rate limiting feature should be disabled.
63
        if ($this->rateLimit === 0 || $this->rateDuration === 0) {
64
            return;
65
        }
66
67
        $controller = $event->getController();
68
        $loggedIn = (bool) $this->container->get('session')->get('logged_in_user');
69
70
        // No rate limits on index pages or if they are logged in.
71
        if ($controller[1] === 'indexAction' || $loggedIn) {
72
            return;
73
        }
74
75
        $request = $event->getRequest();
76
77
        $this->checkBlacklist($request);
78
79
        // Build and fetch cache key based on session ID and requested URI,
80
        //   removing any reserved characters from URI.
81
        $uri = preg_replace('/[^a-zA-Z0-9,\.]/', '', $request->getRequestUri());
82
        $sessionId = $request->getSession()->getId();
83
        $cacheKey = 'ratelimit.'.$sessionId.'.'.$uri;
84
        $cache = $this->container->get('cache.app');
85
        $cacheItem = $cache->getItem($cacheKey);
86
87
        // If increment value already in cache, or start with 1.
88
        $count = $cacheItem->isHit() ? (int) $cacheItem->get() + 1 : 1;
89
90
        // Check if limit has been exceeded, and if so, throw an error.
91
        if ($count > $this->rateLimit) {
92
            $this->denyAccess($request, 'Exceeded rate limitation');
93
        }
94
95
        // Reset the clock on every request.
96
        $cacheItem->set($count)
97
            ->expiresAfter(new DateInterval('PT'.$this->rateDuration.'M'));
98
        $cache->save($cacheItem);
99
    }
100
101
    /**
102
     * Check the request against blacklisted URIs and user agents
103
     * @param \Symfony\Component\HttpFoundation\Request $request
104
     */
105
    private function checkBlacklist(\Symfony\Component\HttpFoundation\Request $request)
106
    {
107
        // First check user agent and URI blacklists
108
        if ($this->container->hasParameter('request_blacklist')) {
109
            $blacklist = $this->container->getParameter('request_blacklist');
110
            // User agents
111
            if (is_array($blacklist['user_agent'])) {
112
                foreach ($blacklist['user_agent'] as $ua) {
113
                    if (strpos($request->headers->get('User-Agent'), $ua) !== false) {
114
                        $this->denyAccess($request, "Matched blacklisted user agent `$ua`");
115
                    }
116
                }
117
            }
118
            // URIs
119
            if (is_array($blacklist['uri'])) {
120
                foreach ($blacklist['uri'] as $uri) {
121
                    if (strpos($request->getRequestUri(), $uri) !== false) {
122
                        $this->denyAccess($request, "Matched blacklisted URI `$uri`");
123
                    }
124
                }
125
            }
126
        }
127
    }
128
129
    /**
130
     * Throw exception for denied access due to spider crawl or hitting usage limits.
131
     * @param \Symfony\Component\HttpFoundation\Request $request
132
     * @param string $logComment Comment to include with the log entry.
133
     * @todo i18n
134
     */
135
    private function denyAccess(\Symfony\Component\HttpFoundation\Request $request, $logComment = '')
136
    {
137
        // Log the denied request
138
        $logger = $this->container->get('monolog.logger.rate_limit');
139
        $logger->info(
140
            "<URI>: " . $request->getRequestUri() .
141
            ($logComment != '' ? "\t<Reason>: $logComment" : '') .
142
            "\t<User agent>: " . $request->headers->get('User-Agent')
143
        );
144
145
        throw new AccessDeniedHttpException("Possible spider crawl detected. " .
146
            'If you are human, you are making too many requests during a short period of time. ' .
147
            "Please wait $this->rateDuration minutes before reloading this tool. You can then " .
148
            'login to prevent this from happening again.');
149
    }
150
}
151