From bb7304d7931cedc91301a63748f282b767933987 Mon Sep 17 00:00:00 2001 From: overflowerror Date: Sat, 25 Nov 2023 14:27:39 +0100 Subject: [PATCH] feat: Add ratelimit middleware --- controllers/routes.php | 13 +++++- controllers/whois/GET.php | 4 +- middleware/log.php | 9 +++- middleware/ratelimit.php | 46 +++++++++++++++++++ .../migrations/0001_addLoggingTable.sql | 1 + persistence/models/LogEntry.php | 3 ++ persistence/repositories/Logs.php | 34 ++++++++++++++ 7 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 middleware/ratelimit.php diff --git a/controllers/routes.php b/controllers/routes.php index fc4df12..ebd31f9 100644 --- a/controllers/routes.php +++ b/controllers/routes.php @@ -4,11 +4,12 @@ require_once(ROOT . "/router/Router.php"); require_once(ROOT . "/middleware/renderer.php"); require_once(ROOT . "/middleware/log.php"); +require_once(ROOT . "/middleware/ratelimit.php"); function fromController(string $path, string $endpoint = null) { return function(array &$context) use ($path, $endpoint) { if ($endpoint) - $context["endpoint"] = $endpoint; + $context[ENDPOINT] = $endpoint; return (require(ROOT . "/controllers/" . $path . ".php"))($context); }; @@ -27,7 +28,15 @@ return function(Router $router) { fromController("/ipaddress/GET", "ipaddress") ); $apiRouter->addRoute(GET, "/whois", - fromController("/whois/GET", "whois") + useRateLimit( + fromController("/whois/GET", "whois"), + [ + RATELIMIT_ENDPOINT => "whois", + RATELIMIT_AMOUNT_PER_IP => 1, + RATELIMIT_AMOUNT_PER_KEY => 10, + RATELIMIT_TIMEFRAME => 60, + ] + ) ); $apiRouter->addRoute(GET, "/punycode", diff --git a/controllers/whois/GET.php b/controllers/whois/GET.php index 6633b5a..2d4ff35 100644 --- a/controllers/whois/GET.php +++ b/controllers/whois/GET.php @@ -95,7 +95,7 @@ function whoisDomain(string $domain) { return function (array &$context) { if (key_exists("ip", $_GET)) { - $context["endpoint"] = "whois/ip"; + $context[ENDPOINT_DETAILS] = "whois/ip"; $ip = $_GET["ip"]; if (!filter_var($ip, FILTER_VALIDATE_IP)) { @@ -105,7 +105,7 @@ return function (array &$context) { return whoisIp($ip); } } elseif (key_exists("domain", $_GET)) { - $context["endpoint"] = "whois/domain"; + $context[ENDPOINT_DETAILS] = "whois/domain"; $domain = $_GET["domain"]; return whoisDomain($domain); diff --git a/middleware/log.php b/middleware/log.php index 0f9aac8..c1b35ca 100644 --- a/middleware/log.php +++ b/middleware/log.php @@ -2,16 +2,21 @@ require_once(ROOT . "/persistence/models/LogEntry.php"); +const ENDPOINT = "ENDPOINT"; +const ENDPOINT_DETAILS = "ENDPOINT_DETAILS"; + function useLog($handler, string $endpoint = "") { return function (array &$context) use ($handler, $endpoint) { - $context["endpoint"] = $endpoint; + $context[ENDPOINT] = $endpoint; + $context[ENDPOINT_DETAILS] = ""; $result = $handler($context); $accessKey = $context["ACCESS_KEY"] ?? ""; $entry = new LogEntry( - $context["endpoint"], + $context[ENDPOINT], + $context[ENDPOINT_DETAILS], $_SERVER['REMOTE_ADDR'], $accessKey, ); diff --git a/middleware/ratelimit.php b/middleware/ratelimit.php new file mode 100644 index 0000000..bee13d1 --- /dev/null +++ b/middleware/ratelimit.php @@ -0,0 +1,46 @@ +logs()->countWithKeyInTimeframe( + $endpoint, + $accessKey, + $timeframe + ); + + $rateLimitReached = $numberOfRequests > $settings[RATELIMIT_AMOUNT_PER_KEY]; + } else if(key_exists(RATELIMIT_AMOUNT_PER_IP, $settings)) { + $numberOfRequests = $context[REPOSITORIES]->logs()->countWithAddressInTimeframe( + $endpoint, + $clientAddress, + $timeframe + ); + + $rateLimitReached = $numberOfRequests > $settings[RATELIMIT_AMOUNT_PER_IP]; + } + + if ($rateLimitReached) { + setStatusCode(429); + return errorResponse("Rate limit reached", "Please wait before retrying."); + } else { + return $handler($context); + } + }; +} \ No newline at end of file diff --git a/persistence/migrations/0001_addLoggingTable.sql b/persistence/migrations/0001_addLoggingTable.sql index 91be526..a4ea427 100644 --- a/persistence/migrations/0001_addLoggingTable.sql +++ b/persistence/migrations/0001_addLoggingTable.sql @@ -3,6 +3,7 @@ CREATE TABLE `ua_logs` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `timestamp` DATETIME DEFAULT CURRENT_TIMESTAMP, `endpoint` VARCHAR(100), + `endpoint_details` VARCHAR(100), `client_address` VARCHAR(46), `access_key` VARCHAR(255), `clean` BOOLEAN DEFAULT 0, diff --git a/persistence/models/LogEntry.php b/persistence/models/LogEntry.php index 07a41cf..e2be9ce 100644 --- a/persistence/models/LogEntry.php +++ b/persistence/models/LogEntry.php @@ -4,16 +4,19 @@ class LogEntry { public int $id; public DateTime $timestamp; public string $endpoint; + public string $endpointDetails; public string $clientAddress; public string $accessKey; public bool $isClean; public function __construct( string $endpoint, + string $endpointDetails, string $clientAddress, string $accessKey, ) { $this->endpoint = $endpoint; + $this->endpointDetails = $endpointDetails; $this->clientAddress = $clientAddress; $this->accessKey = $accessKey; } diff --git a/persistence/repositories/Logs.php b/persistence/repositories/Logs.php index 8b9f472..fc6d40e 100644 --- a/persistence/repositories/Logs.php +++ b/persistence/repositories/Logs.php @@ -23,4 +23,38 @@ class Logs { $entry->accessKey, ]); } + + public function countWithKeyInTimeframe(string $endpoint, string $key, string $timeframe) { + $statement = $this->connection->prepare(<<table` + WHERE + `endpoint` = ? AND + `key` = ? AND + `timestamp` > ? + EOF); + $statement->execute([ + $endpoint, + $key, + (new DateTime())->sub(DateInterval::createFromDateString($timeframe)) + ]); + return $statement->fetchColumn()[0]; + } + + public function countWithAddressInTimeframe(string $endpoint, string $address, int $timeframe) { + $statement = $this->connection->prepare(<<table` + WHERE + `endpoint` = ? AND + `client_address` = ? AND + `timestamp` > CURRENT_TIMESTAMP - INTERVAL ? SECOND + EOF); + $statement->execute([ + $endpoint, + $address, + $timeframe, + ]); + return $statement->fetchColumn(); + } }