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