diff --git a/app/DoctrineMigrations/Version20191026154349.php b/app/DoctrineMigrations/Version20191026154349.php new file mode 100644 index 000000000..0e46b442b --- /dev/null +++ b/app/DoctrineMigrations/Version20191026154349.php @@ -0,0 +1,28 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE acts_rate_limit_entries (id INT AUTO_INCREMENT NOT NULL, ip_address INT NOT NULL, endpoint VARCHAR(255) NOT NULL, occurred_at DATETIME NOT NULL, PRIMARY KEY(id))'); + } + + public function down(Schema $schema) : void + { + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE acts_rate_limit_entries'); + } +} diff --git a/app/config/security.yml b/app/config/security.yml index b544b214a..2f70a047a 100644 --- a/app/config/security.yml +++ b/app/config/security.yml @@ -16,6 +16,13 @@ security: pattern: ^/oauth/v2/token security: false + api: + pattern: ^/.*\.(json|xml) + host: ^(?!(localhost)$)(\w*)$ + fos_oauth: true + stateless: true + anonymous: false + public: pattern: ^/.* anonymous: true @@ -54,7 +61,7 @@ security: security: false access_control: -# - { path ^/.*\.(json|xml)$, roles: ROLE_API } + - { path ^/.*\.(json|xml), roles: IS_AUTHENTICATED_FULLY } encoders: Acts\CamdramSecurityBundle\Entity\User: @@ -91,4 +98,4 @@ hwi_oauth: csrf: true include_email: true raven: - service: Acts\CamdramSecurityBundle\Security\RavenResourceOwner \ No newline at end of file + service: Acts\CamdramSecurityBundle\Security\RavenResourceOwner diff --git a/src/Acts/CamdramApiBundle/Entity/RateLimitEntry.php b/src/Acts/CamdramApiBundle/Entity/RateLimitEntry.php new file mode 100644 index 000000000..1788499b8 --- /dev/null +++ b/src/Acts/CamdramApiBundle/Entity/RateLimitEntry.php @@ -0,0 +1,70 @@ +id; + } + + public function getIpAddress() + { + return $this->id; + } + + public function setIpAddress($ip_address) + { + $int = ip2long($ip_address); + $this->ip_address = $int; + } + + public function getEndpoint() + { + return $this->endpoint; + } + + public function setEndpoint($endpoint) + { + $this->endpoint = $endpoint; + } + + public function getOccurredAt() + { + return $this->occurred_at; + } + + public function setOccurredAt($occurred_at) + { + $this->occurred_at = $occurred_at; + } +} diff --git a/src/Acts/CamdramApiBundle/Entity/RateLimitEntryRepository.php b/src/Acts/CamdramApiBundle/Entity/RateLimitEntryRepository.php new file mode 100644 index 000000000..67146625a --- /dev/null +++ b/src/Acts/CamdramApiBundle/Entity/RateLimitEntryRepository.php @@ -0,0 +1,23 @@ +createQueryBuilder('entry') + ->select('COUNT(entry.id)') + ->where('entry.ip_address = :ip') + ->andWhere('entry.occurred_at > :when') + ->setParameter('ip', $ip) + ->setParameter('when', $when) + ->getQuery(); + return $query->getOneOrNullResult()[1]; + } +} diff --git a/src/Acts/CamdramApiBundle/EventListener/KernelEventListener.php b/src/Acts/CamdramApiBundle/EventListener/KernelEventListener.php index 10c65f33c..55c157bfd 100644 --- a/src/Acts/CamdramApiBundle/EventListener/KernelEventListener.php +++ b/src/Acts/CamdramApiBundle/EventListener/KernelEventListener.php @@ -5,19 +5,29 @@ use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpFoundation\Response; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Acts\CamdramApiBundle\Entity\RateLimitEntry; class KernelEventListener { private $entityManager; + private $tokenStorage; - public function __construct(EntityManagerInterface $entityManager) + public function __construct(EntityManagerInterface $entityManager, TokenStorageInterface $tokenStorage) { $this->entityManager = $entityManager; + $this->tokenStorage = $tokenStorage; } public function onKernelRequest(GetResponseEvent $event) { - $params = $event->getRequest()->request; + /** + * In this section of code we determine if an app is making a request + * and, if it is, we increment its total request counter by one and + * set the date of its last request to the current date & time. + */ + $request = $event->getRequest(); + $params = $request->request; // The client_id POST parameter has the database primary key embedded $parts = explode("_", $params->get("client_id")); $clientSecret = $params->get("client_secret"); @@ -25,15 +35,77 @@ public function onKernelRequest(GetResponseEvent $event) $id = $parts[0]; $clientId = $parts[1]; if ($id && $clientId && $clientSecret) { - $appRepo = $this->entityManager->getRepository('ActsCamdramApiBundle:ExternalApp'); - $app = $appRepo->findByCredentials($id, $clientId, $clientSecret); - if ($app) { - $now = new \DateTime; - $app->incrementRequestCounter(); - $app->setLastUsed($now); - $this->entityManager->flush(); - } + $this->incrementAppRequestCounter($id, $clientId, $clientSecret); } } + + /** + * In this section we attempt to prevent common programming libraries + * and software development tools from making requests unless they are + * using a unique User-Agent string. We allow all requests in testing + * in order to make our lives easier. + */ + if (getenv("SYMFONY_ENV") !== 'test') { + $this->checkUserAgentHeader($event, $request); + } + + /** + * In this section we rate limit and log incoming requests. Each + * individual public IP address is limited to a maximum of 200 requests + * per minute. Currently this limit is applied equally to all endpoints + * without differentiation between web browsers and API client apps, + * although this may change in the future. + */ + $this->checkRateLimit($event, $request); + $this->logRateLimitEntry($request); + } + + private function incrementAppRequestCounter($id, $clientId, $clientSecret) { + $appRepo = $this->entityManager->getRepository('ActsCamdramApiBundle:ExternalApp'); + $app = $appRepo->findByCredentials($id, $clientId, $clientSecret); + if ($app) { + $now = new \DateTime; + $app->incrementRequestCounter(); + $app->setLastUsed($now); + $this->entityManager->flush(); + } + } + + private function checkUserAgentHeader($event, $request) { + $headers = $request->headers; + $user_agent = $headers->get('User-Agent'); + $known_agents = array("AdobeAIR", "TALWinInetHTTPClient", "android-async-http", "Dalvik", "Anemone", "AngleSharp", "Apache-HttpClient", "Apache-HttpAsyncClient", "AHC", "axios", "BinGet", "CFNetwork", "Chilkat", "CsQuery", "cssutils", "curl", "libcurl", "EventMachine", "HttpClient", "Faraday", "Feed::Find", "Go-http-client", "http-client", "Go http package", "Goose", "got", "GStreamer", "souphttpsrc", "libsoup", "Guzzle", "GuzzleHttp", "hackney", "htmlayout", "http-kit", "HTTP_Request", "HTTP_Request2", "Indy Library", "Incutio", "Jakarta Commons-HttpClient", "Java", "libsoup", "libwww-perl", "lua-resty-http", "lwp-trivial", "LWP::Simple", "Manticore", "Mechanize", "Microsoft BITS", "Mojolicious", "okhttp", "PEAR", "HTTP_Request", "PECL::HTTP", "PHP-Curl-Class", "PHPCrawl", "Poe-Component-Client", "PycURL", "python-requests", "Python-urllib", "Python-webchecker", "longurl-r-package", "RestSharp", "RPT-HTTPClient", "eat", "Snoopy", "libsummer", "Symfony", "BrowserKit", "Typhoeus", "unirest-net", "urlgrabber", "WLMHttpTransport", "WinHttp", "WinInet", "WWW-Mechanize", "xine", "Zend_Http_Client"); + foreach ($known_agents as $agent_stub) { + if (strpos($user_agent, $agent_stub) !== false) { + $response = new Response(); + $response->setStatusCode(Response::HTTP_BAD_REQUEST); + $response->setContent("Bad Request. You need to use a unique User-Agent string."); + $event->setResponse($response); + } + } + } + + private function checkRateLimit($event, $request) { + $ip_address = $request->getClientIp(); + $rateLimitEntryRepo = $this->entityManager->getRepository('ActsCamdramApiBundle:RateLimitEntry'); + $numOfRequests = $rateLimitEntryRepo->count($ip_address); + if ($numOfRequests > 200) { + $response = new Response(); + $response->setStatusCode(Response::HTTP_TOO_MANY_REQUESTS); + $response->setContent("You have been rate limited. Try again later."); + $event->setResponse($response); + } + } + + private function logRateLimitEntry($request) { + $ip_address = $request->getClientIp(); + $path_info = $request->getPathInfo(); + $now = new \DateTime; + $rateLimitEntry = new RateLimitEntry(); + $rateLimitEntry->setIpAddress($ip_address); + $rateLimitEntry->setEndpoint($path_info); + $rateLimitEntry->setOccurredAt($now); + $this->entityManager->persist($rateLimitEntry); + $this->entityManager->flush(); } }