From 6c39c292b070d66500ecc9e77f3851bd609f05a0 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 23 Apr 2024 20:14:32 +0200 Subject: [PATCH] Redis + RateLimiting --- Core/API/Request.class.php | 50 +++++++++------ Core/API/TfaAPI.class.php | 10 +++ Core/API/Traits/Pagination.trait.php | 1 + Core/API/UserAPI.class.php | 30 +++++---- Core/Configuration/Settings.class.php | 43 ++++++++++++- Core/Documents/Info.class.php | 5 +- Core/Documents/Install.class.php | 27 +++++--- Core/Driver/Redis/RedisConnection.class.php | 66 +++++++++++++++++++ Core/Objects/ConnectionData.class.php | 4 +- Core/Objects/Context.class.php | 22 +++++++ Core/Objects/RateLimitRule.class.php | 31 +++++++++ Core/Objects/RateLimiting.class.php | 71 +++++++++++++++++++++ Core/core.php | 4 ++ docker-compose.yml | 12 ++++ docker/php/Dockerfile | 2 +- 15 files changed, 330 insertions(+), 48 deletions(-) create mode 100644 Core/Driver/Redis/RedisConnection.class.php create mode 100644 Core/Objects/RateLimitRule.class.php create mode 100644 Core/Objects/RateLimiting.class.php diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php index 65edef6..ffb1434 100644 --- a/Core/API/Request.class.php +++ b/Core/API/Request.class.php @@ -5,6 +5,7 @@ namespace Core\API; use Core\Driver\Logger\Logger; use Core\Objects\Context; use Core\Objects\DatabaseEntity\TwoFactorToken; +use Core\Objects\RateLimiting; use Core\Objects\TwoFactor\KeyBasedTwoFactorToken; use PhpMqtt\Client\MqttClient; @@ -23,6 +24,7 @@ abstract class Request { protected bool $isDisabled; protected bool $apiKeyAllowed; protected bool $csrfTokenRequired; + protected ?RateLimiting $rateLimiting; private array $defaultParams; private array $allowedMethods; @@ -47,6 +49,7 @@ abstract class Request { $this->apiKeyAllowed = true; $this->allowedMethods = array("GET", "POST"); $this->csrfTokenRequired = true; + $this->rateLimiting = null; } public function getAPIName(): string { @@ -192,7 +195,26 @@ abstract class Request { $this->result['logoutIn'] = $session->getExpiresSeconds(); } + if ($this->isDisabled) { + $this->lastError = "This function is currently disabled."; + http_response_code(503); + return false; + } + + $sql = $this->context->getSQL(); + if ($sql === null || !$sql->isConnected()) { + $this->lastError = $sql ? $sql->getLastError() : "Database not connected yet."; + http_response_code(503); + return false; + } + if ($this->externalCall) { + if (!$this->isPublic) { + $this->lastError = 'This function is private.'; + http_response_code(403); + return false; + } + $values = $_REQUEST; if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"] ?? ""))) { $jsonData = json_decode(file_get_contents('php://input'), true); @@ -204,21 +226,6 @@ abstract class Request { return false; } } - } - - if ($this->isDisabled) { - $this->lastError = "This function is currently disabled."; - http_response_code(503); - return false; - } - - if ($this->externalCall && !$this->isPublic) { - $this->lastError = 'This function is private.'; - http_response_code(403); - return false; - } - - if ($this->externalCall) { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); # No content @@ -292,10 +299,15 @@ abstract class Request { $this->parseVariableParams($values); } - $sql = $this->context->getSQL(); - if ($sql === null || !$sql->isConnected()) { - $this->lastError = $sql ? $sql->getLastError() : "Database not connected yet."; - return false; + if ($this->externalCall && $this->rateLimiting) { + $settings = $this->context->getSettings(); + if ($settings->isRateLimitingEnabled()) { + if (!$this->rateLimiting->check($this->context, self::getEndpoint())) { + http_response_code(429); + $this->lastError = "Rate limit exceeded"; + return false; + } + } } $this->success = true; diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php index 31c8b88..8fc82c6 100644 --- a/Core/API/TfaAPI.class.php +++ b/Core/API/TfaAPI.class.php @@ -58,6 +58,8 @@ namespace Core\API\TFA { use Core\API\Parameter\StringType; use Core\API\TfaAPI; use Core\Objects\Context; + use Core\Objects\RateLimiting; + use Core\Objects\RateLimitRule; use Core\Objects\TwoFactor\AttestationObject; use Core\Objects\TwoFactor\AuthenticationData; use Core\Objects\TwoFactor\KeyBasedTwoFactorToken; @@ -214,6 +216,10 @@ namespace Core\API\TFA { ]); $this->loginRequired = true; $this->csrfTokenRequired = false; + $this->rateLimiting = new RateLimiting( + null, + new RateLimitRule(5, 30, RateLimitRule::SECOND) + ); } public function _execute(): bool { @@ -347,6 +353,10 @@ namespace Core\API\TFA { ]); $this->loginRequired = true; $this->csrfTokenRequired = false; + $this->rateLimiting = new RateLimiting( + null, + new RateLimitRule(20, 60, RateLimitRule::SECOND) + ); } public function _execute(): bool { diff --git a/Core/API/Traits/Pagination.trait.php b/Core/API/Traits/Pagination.trait.php index 961445a..9e23534 100644 --- a/Core/API/Traits/Pagination.trait.php +++ b/Core/API/Traits/Pagination.trait.php @@ -29,6 +29,7 @@ trait Pagination { $this->paginationCondition = $condition; $this->entityCount = call_user_func("$this->paginationClass::count", $sql, $condition, $joins); $this->pageSize = $this->getParam("count"); + $this->page = $this->getParam("page"); if ($this->entityCount === false) { return $this->createError("Error fetching $this->paginationClass::count: " . $sql->getLastError()); } diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index c1e1a99..266d0a6 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -137,6 +137,8 @@ namespace Core\API\User { use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Expression\JsonArrayAgg; + use Core\Objects\RateLimiting; + use Core\Objects\RateLimitRule; use Core\Objects\TwoFactor\KeyBasedTwoFactorToken; use ImagickException; use Core\Objects\Context; @@ -563,6 +565,9 @@ namespace Core\API\User { 'token' => new StringType('token', 36) )); $this->csrfTokenRequired = false; + $this->rateLimiting = new RateLimiting( + new RateLimitRule(5, 1, RateLimitRule::MINUTE) + ); } public function _execute(): bool { @@ -601,8 +606,6 @@ namespace Core\API\User { class Login extends UserAPI { - private int $startedAt; - public function __construct(Context $context, $externalCall = false) { parent::__construct($context, $externalCall, array( 'username' => new StringType('username'), @@ -610,13 +613,9 @@ namespace Core\API\User { 'stayLoggedIn' => new Parameter('stayLoggedIn', Parameter::TYPE_BOOLEAN, true, false) )); $this->forbidMethod("GET"); - } - - private function wrongCredentials(): bool { - $runtime = microtime(true) - $this->startedAt; - $sleepTime = round(3e6 - $runtime); - if ($sleepTime > 0) usleep($sleepTime); - return $this->createError(L('Wrong username or password')); + $this->rateLimiting = new RateLimiting( + new RateLimitRule(10, 30, RateLimitRule::SECOND) + ); } public function _execute(): bool { @@ -635,7 +634,6 @@ namespace Core\API\User { return true; } - $this->startedAt = microtime(true); $this->success = false; $username = $this->getParam('username'); $password = $this->getParam('password'); @@ -648,7 +646,7 @@ namespace Core\API\User { if ($user !== false) { if ($user === null) { - return $this->wrongCredentials(); + return $this->createError(L('Wrong username or password')); } else if (!$user->isActive()) { return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake."); } else if (password_verify($password, $user->password)) { @@ -668,7 +666,7 @@ namespace Core\API\User { $this->success = true; } } else { - return $this->wrongCredentials(); + return $this->createError(L('Wrong username or password')); } } else { return $this->createError("Error fetching user details: " . $sql->getLastError()); @@ -1190,14 +1188,18 @@ namespace Core\API\User { class ResetPassword extends UserAPI { public function __construct(Context $context, $externalCall = false) { - parent::__construct($context, $externalCall, array( + parent::__construct($context, $externalCall, [ 'token' => new StringType('token', 36), 'password' => new StringType('password'), 'confirmPassword' => new StringType('confirmPassword'), - )); + ]); + $this->forbidMethod("GET"); $this->csrfTokenRequired = false; $this->apiKeyAllowed = false; + $this->rateLimiting = new RateLimiting( + new RateLimitRule(5, 1, RateLimitRule::MINUTE) + ); } public function _execute(): bool { diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php index ab1cdc5..3fae931 100644 --- a/Core/Configuration/Settings.class.php +++ b/Core/Configuration/Settings.class.php @@ -15,6 +15,7 @@ use Core\Driver\SQL\SQL; use Core\Objects\Captcha\CaptchaProvider; use Core\Objects\Captcha\GoogleRecaptchaProvider; use Core\Objects\Captcha\HCaptchaProvider; +use Core\Objects\ConnectionData; use Core\Objects\Context; class Settings { @@ -41,6 +42,12 @@ class Settings { private string $mailFooter; private bool $mailAsync; + // rate limiting + private bool $rateLimitingEnabled; + private string $redisHost; + private int $redisPort; + private string $redisPassword; + // private Logger $logger; @@ -112,6 +119,18 @@ class Settings { $settings->mailFooter = ""; $settings->mailAsync = false; + // rate limiting + $settings->redisPort = 6379; + if (isDocker()) { + $settings->rateLimitingEnabled = true; + $settings->redisHost = "webbase-redis"; + $settings->redisPassword = "webbase-redis"; + } else { + $settings->rateLimitingEnabled = false; + $settings->redisHost = ""; + $settings->redisPassword = ""; + } + return $settings; } @@ -136,6 +155,10 @@ class Settings { $this->mailAsync = $result["mail_async"] ?? $this->mailAsync; $this->allowedExtensions = $result["allowed_extensions"] ?? $this->allowedExtensions; $this->trustedDomains = $result["trusted_domains"] ?? $this->trustedDomains; + $this->rateLimitingEnabled = $result["rate_limiting_enabled"] ?? $this->rateLimitingEnabled; + $this->redisHost = $result["redis_host"] ?? $this->redisHost; + $this->redisPort = $result["redis_port"] ?? $this->redisPort; + $this->redisPassword = $result["redis_password"] ?? $this->redisPassword; date_default_timezone_set($this->timeZone); } @@ -160,7 +183,12 @@ class Settings { ->addRow("mail_from", '""', false, false) ->addRow("mail_last_sync", '""', false, false) ->addRow("mail_footer", '""', false, false) - ->addRow("mail_async", false, false, false); + ->addRow("mail_async", false, false, false) + ->addRow("rate_limiting_enabled", json_encode($this->allowedExtensions), false, false) + ->addRow("redis_host", json_encode($this->redisHost), false, false) + ->addRow("redis_port", json_encode($this->redisPort), false, false) + ->addRow("redis_password", json_encode($this->redisPassword), true, false) + ; } public function getSiteName(): string { @@ -240,4 +268,17 @@ class Settings { public function getTrustedDomains(): array { return $this->trustedDomains; } + + public function isRateLimitingEnabled(): bool { + return $this->rateLimitingEnabled; + } + + public function getRedisConfiguration(): ConnectionData { + return new ConnectionData( + $this->redisHost, + $this->redisPort, + "", + $this->redisPassword + ); + } } \ No newline at end of file diff --git a/Core/Documents/Info.class.php b/Core/Documents/Info.class.php index 2436cb6..9bf4e0b 100644 --- a/Core/Documents/Info.class.php +++ b/Core/Documents/Info.class.php @@ -19,8 +19,11 @@ class InfoBody extends SimpleBody { protected function getContent(): string { $user = $this->getContext()->getUser(); if ($user && $user->hasGroup(Group::ADMIN)) { + ob_start(); phpinfo(); - return ""; + $content = ob_get_contents(); + ob_end_clean(); + return $content; } else { $message = "You are not logged in or do not have the proper privileges to access this page."; return $this->getDocument()->getRouter()->returnStatusCode(403, [ "message" => $message] ); diff --git a/Core/Documents/Install.class.php b/Core/Documents/Install.class.php index cbdb93b..35f0708 100644 --- a/Core/Documents/Install.class.php +++ b/Core/Documents/Install.class.php @@ -93,10 +93,6 @@ namespace Documents\Install { $this->steps = array(); } - function isDocker(): bool { - return file_exists("/.dockerenv"); - } - private function getParameter($name): ?string { if (isset($_REQUEST[$name]) && is_string($_REQUEST[$name])) { return trim($_REQUEST[$name]); @@ -263,6 +259,11 @@ namespace Documents\Install { $success = false; } + if (!class_exists("Redis")) { + $failedRequirements[] = "redis extension is not installed."; + $success = false; + } + if (!function_exists("yaml_emit")) { $failedRequirements[] = "YAML extension is not installed."; $success = false; @@ -363,7 +364,7 @@ namespace Documents\Install { $connectionData->setProperty('database', $database); $connectionData->setProperty('encoding', $encoding); $connectionData->setProperty('type', $type); - $connectionData->setProperty('isDocker', $this->isDocker()); + $connectionData->setProperty('isDocker', isDocker()); $sql = SQL::createConnection($connectionData); $success = false; if (is_string($sql)) { @@ -706,11 +707,17 @@ namespace Documents\Install { private function createProgressMainview(): string { - $isDocker = $this->isDocker(); - $defaultHost = ($isDocker ? "db" : "localhost"); - $defaultUsername = ($isDocker ? "root" : ""); - $defaultPassword = ($isDocker ? "webbasedb" : ""); - $defaultDatabase = ($isDocker ? "webbase" : ""); + if (isDocker()) { + $defaultHost = "db"; + $defaultUsername = "root"; + $defaultPassword = "webbasedb"; + $defaultDatabase = "webbase"; + } else { + $defaultHost = "localhost"; + $defaultUsername = ""; + $defaultPassword = ""; + $defaultDatabase = ""; + } $views = array( self::CHECKING_REQUIREMENTS => array( diff --git a/Core/Driver/Redis/RedisConnection.class.php b/Core/Driver/Redis/RedisConnection.class.php new file mode 100644 index 0000000..c07ad71 --- /dev/null +++ b/Core/Driver/Redis/RedisConnection.class.php @@ -0,0 +1,66 @@ +logger = new Logger("Redis", $sql); + $this->link = new \Redis(); + } + + public function connect(ConnectionData $connectionData): bool { + try { + $this->link->connect($connectionData->getHost(), $connectionData->getPort()); + $this->link->auth($connectionData->getPassword()); + return true; + } catch (\RedisException $e) { + $this->logger->error("Error connecting to redis: " . $e->getMessage()); + return false; + } + } + + public function isConnected(): bool { + try { + return $this->link->isConnected(); + } catch (\RedisException $e) { + $this->logger->error("Error checking redis connection: " . $e->getMessage()); + return false; + } + } + + public function hGet(string $hashKey, string $key): ?string { + try { + return $this->link->hGet($hashKey, $key); + } catch (\RedisException $e) { + $this->logger->error("Error fetching value from redis: " . $e->getMessage()); + return null; + } + } + + public function hSet(string $hashKey, mixed $key, string $value): bool { + try { + return $this->link->hSet($hashKey, $key, $value); + } catch (\RedisException $e) { + $this->logger->error("Error setting value: " . $e->getMessage()); + return false; + } + } + + public function close(): bool { + try { + return $this->link->close(); + } catch (\RedisException) { + return false; + } + } + +} \ No newline at end of file diff --git a/Core/Objects/ConnectionData.class.php b/Core/Objects/ConnectionData.class.php index 3a620ca..f92bd74 100644 --- a/Core/Objects/ConnectionData.class.php +++ b/Core/Objects/ConnectionData.class.php @@ -10,12 +10,12 @@ class ConnectionData { private string $password; private array $properties; - public function __construct($host, $port, $login, $password) { + public function __construct(string $host, int $port, string $login, string $password) { $this->host = $host; $this->port = $port; $this->login = $login; $this->password = $password; - $this->properties = array(); + $this->properties = []; } public function getProperties(): array { diff --git a/Core/Objects/Context.class.php b/Core/Objects/Context.class.php index 4975275..27f2843 100644 --- a/Core/Objects/Context.class.php +++ b/Core/Objects/Context.class.php @@ -4,6 +4,7 @@ namespace Core\Objects; use Core\Configuration\Configuration; use Core\Configuration\Settings; +use Core\Driver\Redis\RedisConnection; use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondLike; @@ -25,6 +26,7 @@ class Context { private Configuration $configuration; private Language $language; public ?Router $router; + private ?RedisConnection $redis; private function __construct() { @@ -32,6 +34,7 @@ class Context { $this->session = null; $this->user = null; $this->router = null; + $this->redis = null; $this->configuration = new Configuration(); $this->setLanguage(Language::DEFAULT_LANGUAGE()); @@ -52,6 +55,11 @@ class Context { $this->sql->close(); $this->sql = null; } + + if ($this->redis && $this->redis->isConnected()) { + $this->redis->close(); + $this->redis = null; + } } public function setLanguage(Language $language): void { @@ -201,4 +209,18 @@ class Context { return $query->execute(); } + + public function getRedis(): ?RedisConnection { + + if ($this->redis === null) { + $settings = $this->getSettings(); + $connectionData = $settings->getRedisConfiguration(); + $this->redis = new RedisConnection($this->sql); + if (!$this->redis->connect($connectionData)) { + $this->redis = null; + } + } + + return $this->redis; + } } \ No newline at end of file diff --git a/Core/Objects/RateLimitRule.class.php b/Core/Objects/RateLimitRule.class.php new file mode 100644 index 0000000..2d98d67 --- /dev/null +++ b/Core/Objects/RateLimitRule.class.php @@ -0,0 +1,31 @@ +count = $count; + $this->perSecond = $time; + if ($unit === self::HOUR) { + $this->perSecond *= 60 * 60; + } else if ($unit === self::MINUTE) { + $this->perSecond *= 60; + } + } + + public function getCount(): int { + return $this->count; + } + + public function getWindow(): int { + return $this->perSecond; + } +} \ No newline at end of file diff --git a/Core/Objects/RateLimiting.class.php b/Core/Objects/RateLimiting.class.php new file mode 100644 index 0000000..82356a2 --- /dev/null +++ b/Core/Objects/RateLimiting.class.php @@ -0,0 +1,71 @@ +anonymousRule = $anonymousRule; + $this->sessionRule = $sessionRule; + } + + public function check(Context $context, string $method): bool { + $session = $context->getSession(); + $logger = new Logger("RateLimiting", $context->getSQL()); + + if ($session !== null) { + // session based rate limiting + $key = $session->getUUID(); + $effectiveRule = $this->sessionRule; + } else { + // ip-based rate limiting + $key = $_SERVER['REMOTE_ADDR']; + $effectiveRule = $this->anonymousRule; + } + + if ($effectiveRule === null) { + return true; + } + + $redis = $context->getRedis(); + if (!$redis?->isConnected()) { + $logger->error("Could not check rate limiting, redis is not connected."); + return true; + } + + $now = time(); + $queue = json_decode($redis->hGet($method, $key)) ?? []; + $pass = true; + $queueSize = count($queue); + if ($queueSize >= $effectiveRule->getCount()) { + // check the last n entries, whether they fit in the current window + $requestsInWindow = 0; + foreach ($queue as $accessTime) { + if ($accessTime >= $now - $effectiveRule->getWindow()) { + $requestsInWindow++; + if ($requestsInWindow >= $effectiveRule->getCount()) { + $pass = false; + break; + } + } else { + break; + } + } + } + + if ($pass) { + array_unshift($queue, $now); + if ($queueSize + 1 > $effectiveRule->getCount()) { + $queue = array_slice($queue, 0, $effectiveRule->getCount()); + } + $redis->hSet($method, $key, json_encode($queue)); + } + + return $pass; + } +} \ No newline at end of file diff --git a/Core/core.php b/Core/core.php index 86cb061..e7552f2 100644 --- a/Core/core.php +++ b/Core/core.php @@ -259,6 +259,10 @@ function getClassName($class, bool $short = true): string { } } +function isDocker(): bool { + return file_exists("/.dockerenv"); +} + function createError($msg): array { return ["success" => false, "msg" => $msg]; } diff --git a/docker-compose.yml b/docker-compose.yml index 0b2b8c2..5072a38 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,3 +29,15 @@ services: context: './docker/php/' links: - db + - cache + cache: + container_name: webbase-redis + image: redis:latest + ports: + - '6379:6379' + command: redis-server --save 20 1 --loglevel warning --requirepass webbase-redis + volumes: + - cache:/data +volumes: + cache: + driver: local \ No newline at end of file diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 1b4ab4a..c652dfe 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -12,7 +12,7 @@ RUN mkdir -p /application/Core/Configuration /var/www/.gnupg && \ # YAML + dev dependencies + additional packages RUN apt-get update -y && \ apt-get install -y libyaml-dev libzip-dev libgmp-dev libpng-dev libmagickwand-dev gnupg2 git $ADDITIONAL_PACKAGES && \ - printf "\n" | pecl install yaml imagick && docker-php-ext-enable yaml imagick && \ + printf "\n" | pecl install yaml imagick redis && docker-php-ext-enable yaml imagick redis && \ docker-php-ext-install gd # Browscap