CVE-2023-2666: Allocation of Resources Without Limits or Throttling (CWE-770)
value: Allocation of Resources Without Limits or Throttling in GitHub repository froxlor/froxlor prior to 2.0.16.
CWE description: The product allocates a reusable resource or group of resources on behalf of an actor without imposing any restrictions on the size or number of resources that can be allocated, in violation of the intended security policy for that actor.
CVSS v3 base score: 7.5
Published: 2023-05-12T01:15Z
Repository: https://github.com/froxlor/froxlor
Language: PHP
Files changed: actions/admin/settings/110.accounts.php, install/froxlor.sql.php, install/install.php, install/updates/froxlor/update_2.x.inc.php, lib/Froxlor/Api/Api.php, lib/Froxlor/Cli/CliCommand.php, lib/Froxlor/Froxlor.php, lib/Froxlor/Http/RateLimiter.php, lib/Froxlor/PhpHelper.php, lib/init.php, lng/de.lng.php, lng/en.lng.php
Commit date: 2023-05-02 10:19:53 +0200
Commit message: introduce http-request rate-limit; smaller fixes

Signed-off-by: Michael Kaufmann <d00p@froxlor.org>

Diff: diff --git a/actions/admin/settings/110.accounts.php b/actions/admin/settings/110.accounts.php
index c3ebcca27d..7a0b92abaa 100644
--- a/actions/admin/settings/110.accounts.php
+++ b/actions/admin/settings/110.accounts.php
@@ -136,10 +136,30 @@ return [
 					'type' => 'text',
 					'default' => '',
 					'save_method' => 'storeSettingField',
 					'advanced_mode' => true
 				],
+				'system_req_limit_per_interval' => [
+					'label' => lng('serversettings.req_limit_per_interval'),
+					'settinggroup' => 'system',
+					'varname' => 'req_limit_per_interval',
+					'type' => 'number',
+					'min' => 30,
+					'default' => 60,
+					'save_method' => 'storeSettingField',
+					'advanced_mode' => true
+				],
+				'system_req_limit_interval' => [
+					'label' => lng('serversettings.req_limit_interval'),
+					'settinggroup' => 'system',
+					'varname' => 'req_limit_interval',
+					'type' => 'number',
+					'min' => 5,
+					'default' => 60,
+					'save_method' => 'storeSettingField',
+					'advanced_mode' => true
+				],
 				'customer_accountprefix' => [
 					'label' => lng('serversettings.accountprefix'),
 					'settinggroup' => 'customer',
 					'varname' => 'accountprefix',
 					'type' => 'text',
diff --git a/install/froxlor.sql.php b/install/froxlor.sql.php
index d37da714d8..95e840aa64 100644
--- a/install/froxlor.sql.php
+++ b/install/froxlor.sql.php
@@ -697,10 +697,12 @@ opcache.validate_timestamps'),
 	('system', 'distribution', ''),
 	('system', 'update_channel', 'stable'),
 	('system', 'updatecheck_data', ''),
 	('system', 'update_notify_last', '2.0.15'),
 	('system', 'traffictool', 'goaccess'),
+	('system', 'req_limit_per_interval', 60),
+	('system', 'req_limit_interval', 60),
 	('api', 'enabled', '0'),
 	('api', 'customer_default', '1'),
 	('2fa', 'enabled', '1'),
 	('panel', 'decimal_places', '4'),
 	('panel', 'adminmail', 'admin@SERVERNAME'),
@@ -741,11 +743,11 @@ opcache.validate_timestamps'),
 	('panel', 'logo_image_login', ''),
 	('panel', 'logo_overridetheme', '0'),
 	('panel', 'logo_overridecustom', '0'),
 	('panel', 'settings_mode', '0'),
 	('panel', 'version', '2.0.15'),
-	('panel', 'db_version', '202303150');
+	('panel', 'db_version', '202304260');
 
 
 DROP TABLE IF EXISTS `panel_tasks`;
 CREATE TABLE `panel_tasks` (
   `id` int(11) unsigned NOT NULL auto_increment,
diff --git a/install/install.php b/install/install.php
index b585c1b6f6..365908afc7 100644
--- a/install/install.php
+++ b/install/install.php
@@ -21,10 +21,11 @@
  * @copyright  the authors
  * @author     Froxlor team <team@froxlor.org>
  * @license    https://files.froxlor.org/misc/COPYING.txt GPLv2
  */
 
+use Froxlor\Http\RateLimiter;
 use Froxlor\UI\Panel\UI;
 use Froxlor\Install\Install;
 
 require dirname(__DIR__) . '/lib/functions.php';
 
@@ -60,8 +61,9 @@ require dirname(__DIR__) . '/vendor/autoload.php';
 require dirname(__DIR__) . '/lib/tables.inc.php';
 
 // init twig
 UI::initTwig(true);
 UI::sendHeaders();
+RateLimiter::run(true);
 
 $installer = new Install();
 $installer->handle();
diff --git a/install/updates/froxlor/update_2.x.inc.php b/install/updates/froxlor/update_2.x.inc.php
index 20daf7b746..30237d7e23 100644
--- a/install/updates/froxlor/update_2.x.inc.php
+++ b/install/updates/froxlor/update_2.x.inc.php
@@ -461,5 +461,14 @@ if (Froxlor::isFroxlorVersion('2.0.13')) {
 
 if (Froxlor::isFroxlorVersion('2.0.14')) {
 	Update::showUpdateStep("Updating from 2.0.14 to 2.0.15", false);
 	Froxlor::updateToVersion('2.0.15');
 }
+
+if (Froxlor::isDatabaseVersion('202303150')) {
+	Update::showUpdateStep("Adding new request rate limit settings");
+	Settings::AddNew("system.req_limit_per_interval", "60");
+	Settings::AddNew("system.req_limit_interval", "60");
+	Update::lastStepStatus(0);
+
+	Froxlor::updateToDbVersion('202304260');
+}
diff --git a/lib/Froxlor/Api/Api.php b/lib/Froxlor/Api/Api.php
index f32d73a62f..ca6f2ba851 100644
--- a/lib/Froxlor/Api/Api.php
+++ b/lib/Froxlor/Api/Api.php
@@ -24,10 +24,11 @@
  */
 
 namespace Froxlor\Api;
 
 use Exception;
+use Froxlor\Http\RateLimiter;
 use Froxlor\Settings;
 use voku\helper\AntiXSS;
 
 class Api
 {
@@ -50,10 +51,12 @@ class Api
 
 		// check whether API interface is enabled after all
 		if (Settings::Get('api.enabled') != 1) {
 			throw new Exception('API is not enabled. Please contact the administrator if you think this is wrong.', 400);
 		}
+
+		RateLimiter::run();
 	}
 
 	/**
 	 * @param mixed $request
 	 *
diff --git a/lib/Froxlor/Cli/CliCommand.php b/lib/Froxlor/Cli/CliCommand.php
index e3dbe7f6f0..0db8c5a284 100644
--- a/lib/Froxlor/Cli/CliCommand.php
+++ b/lib/Froxlor/Cli/CliCommand.php
@@ -120,11 +120,11 @@ class CliCommand extends Command
 	{
 		$output->writeln('<comment>Automatic update is activated and we are going to proceed without any notices</>');
 		include_once Froxlor::getInstallDir() . '/lib/tables.inc.php';
 		define('_CRON_UPDATE', 1);
 		ob_start([
-			'this',
+			$this,
 			'cleanUpdateOutput'
 		]);
 		include_once Froxlor::getInstallDir() . '/install/updatesql.php';
 		ob_end_flush();
 		$output->writeln('<info>Automatic update done - you should check your settings to be sure everything is fine</>');
diff --git a/lib/Froxlor/Froxlor.php b/lib/Froxlor/Froxlor.php
index befe575c6c..5a9295eaeb 100644
--- a/lib/Froxlor/Froxlor.php
+++ b/lib/Froxlor/Froxlor.php
@@ -32,11 +32,11 @@ final class Froxlor
 
 	// Main version variable
 	const VERSION = '2.0.15';
 
 	// Database version (YYYYMMDDC where C is a daily counter)
-	const DBVERSION = '202303150';
+	const DBVERSION = '202304260';
 
 	// Distribution branding-tag (used for Debian etc.)
 	const BRANDING = '';
 
 	/**
diff --git a/lib/Froxlor/Http/RateLimiter.php b/lib/Froxlor/Http/RateLimiter.php
new file mode 100644
index 0000000000..a5934c5dc6
--- /dev/null
+++ b/lib/Froxlor/Http/RateLimiter.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Froxlor\Http;
+
+class RateLimiter
+{
+	private static int $limit_per_interval = 60;
+	private static int $reset_time = 0;
+
+	public static function run(bool $install_mode = false)
+	{
+		// default interval = 60 sec
+		self::$reset_time = time() + 60;
+
+		if (!$install_mode) {
+			self::$limit_per_interval = Settings::Get('system.req_limit_per_interval');
+			self::$reset_time = time() + Settings::Get('system.req_limit_interval');
+		}
+
+		// Get the remaining requests and reset time from the headers
+		$remaining = isset($_SESSION['HTTP_X_RATELIMIT_REMAINING']) ? (int)$_SESSION['HTTP_X_RATELIMIT_REMAINING'] : self::$limit_per_interval;
+		$reset = isset($_SESSION['HTTP_X_RATELIMIT_RESET']) ? (int)$_SESSION['HTTP_X_RATELIMIT_RESET'] : self::$reset_time;
+
+		// check if reset time is due
+		if (time() > $reset) {
+			$remaining = self::$limit_per_interval;
+			$reset = self::$reset_time;
+		}
+
+		// If we've hit the limit, return an error
+		if ($remaining <= 0) {
+			header('HTTP/1.1 429 Too Many Requests');
+			header("Retry-After: $reset");
+			exit();
+		}
+
+		// Decrement the remaining requests and update the headers
+		$remaining--;
+		$_SESSION['HTTP_X_RATELIMIT_REMAINING'] = $remaining;
+		$_SESSION['HTTP_X_RATELIMIT_RESET'] = $reset;
+
+		header("X-RateLimit-Limit: " . self::$limit_per_interval);
+		header("X-RateLimit-Remaining: " . $remaining);
+		header("X-RateLimit-Reset: " . $reset);
+	}
+}
diff --git a/lib/Froxlor/PhpHelper.php b/lib/Froxlor/PhpHelper.php
index b4af5b62df..f5ee808938 100644
--- a/lib/Froxlor/PhpHelper.php
+++ b/lib/Froxlor/PhpHelper.php
@@ -517,11 +517,16 @@ class PhpHelper
 				if (is_bool($value)) {
 					$str .= self::tabPrefix($depth, sprintf("'%s' => %s,\n", $key, $value ? 'true' : 'false'));
 				} elseif (is_int($value)) {
 					$str .= self::tabPrefix($depth, "'{$key}' => $value,\n");
 				} else {
-					$str .= self::tabPrefix($depth, "'{$key}' => '{$value}',\n");
+					if ($key == 'password') {
+						// special case for passwords (nowdoc)
+						$str .= self::tabPrefix($depth, "'{$key}' => <<<'EOT'\n{$value}\nEOT,\n");
+					} else {
+						$str .= self::tabPrefix($depth, "'{$key}' => '{$value}',\n");
+					}
 				}
 			} else {
 				$str .= self::parseArrayToString($value, $key, ($depth + 1));
 			}
 		}
diff --git a/lib/init.php b/lib/init.php
index 78e67f6c83..2b5909bc74 100644
--- a/lib/init.php
+++ b/lib/init.php
@@ -50,10 +50,11 @@ if (!file_exists(dirname(__DIR__) . '/vendor/autoload.php')) {
 require dirname(__DIR__) . '/vendor/autoload.php';
 
 use Froxlor\CurrentUser;
 use Froxlor\Froxlor;
 use Froxlor\FroxlorLogger;
+use Froxlor\Http\RateLimiter;
 use Froxlor\Idna\IdnaWrapper;
 use Froxlor\Language;
 use Froxlor\PhpHelper;
 use Froxlor\Settings;
 use Froxlor\System\Mailer;
@@ -119,10 +120,11 @@ if (!isset($sql) || !is_array($sql)) {
 	'phpExceptionHandler'
 ]);
 
 // send ssl-related headers (later than the others because we need a working database-connection and installation)
 UI::sendSslHeaders();
+RateLimiter::run();
 
 // create a new idna converter
 $idna_convert = new IdnaWrapper();
 
 // re-read user data if logged in
diff --git a/lng/de.lng.php b/lng/de.lng.php
index 8af35cebb8..aeb904292a 100644
--- a/lng/de.lng.php
+++ b/lng/de.lng.php
@@ -2070,10 +2070,18 @@ Vielen Dank, Ihr Administrator',
 		],
 		'traffictool' => [
 			'toolselect' => 'Traffic Analyzer',
 		],
 		'requires_reconfiguration' => 'Änderungen an dieser Einstellungen benötigen unter Umständen eine erneute Konfiguration der folgenden Dienste:<br><strong>%s</strong>',
+		'req_limit_per_interval' => [
+			'title' => 'Anzahl der HTTP-Anfragen pro Intervall',
+			'description' => 'Erlaubte Anzahl von HTTP-Anfragen pro Intervall (siehe unten) auf froxlor, Standard ist "60"',
+		],
+		'req_limit_interval' => [
+			'title' => 'Rate-Limit-Intervall',
+			'description' => 'Specify the time in seconds for the number of HTTP requests, default is "60"',
+		],
 	],
 	'spf' => [
 		'use_spf' => 'Aktiviere SPF für Domains?',
 		'spf_entry' => 'SPF-Eintrag für alle Domains',
 	],
diff --git a/lng/en.lng.php b/lng/en.lng.php
index 47e7f6b42c..9e83d43ca6 100644
--- a/lng/en.lng.php
+++ b/lng/en.lng.php
@@ -2194,10 +2194,18 @@ Yours sincerely, your administrator',
 			'webalizer' => 'Webalizer',
 			'awstats' => 'AWStats',
 			'goaccess' => 'goacccess'
 		],
 		'requires_reconfiguration' => 'Changing this settings might require a reconfiguration of the following services:<br><strong>%s</strong>',
+		'req_limit_per_interval' => [
+			'title' => 'Number of HTTP requests per interval',
+			'description' => 'Limit the number of HTTP requests per interval (see below) to froxlor, default is "60"',
+		],
+		'req_limit_interval' => [
+			'title' => 'Rate-limit interval',
+			'description' => 'Zeit in Sekunden für die maximale Anzahl von HTTP-Anfragen, Standard ist "60".',
+		],
 	],
 	'spf' => [
 		'use_spf' => 'Activate SPF for domains?',
 		'spf_entry' => 'SPF entry for all domains',
 	],

Vulnerable code: 	('panel', 'db_version', '202303150');
			'this',
	const DBVERSION = '202303150';
					$str .= self::tabPrefix($depth, "'{$key}' => '{$value}',\n");
Fixed code: 				'system_req_limit_per_interval' => [
					'label' => lng('serversettings.req_limit_per_interval'),
					'settinggroup' => 'system',
					'varname' => 'req_limit_per_interval',
					'type' => 'number',
					'min' => 30,
					'default' => 60,
					'save_method' => 'storeSettingField',
					'advanced_mode' => true
				],
				'system_req_limit_interval' => [
					'label' => lng('serversettings.req_limit_interval'),
					'settinggroup' => 'system',
					'varname' => 'req_limit_interval',
					'type' => 'number',
					'min' => 5,
					'default' => 60,
					'save_method' => 'storeSettingField',
					'advanced_mode' => true
				],
	('system', 'req_limit_per_interval', 60),
	('system', 'req_limit_interval', 60),
	('panel', 'db_version', '202304260');
use Froxlor\Http\RateLimiter;
RateLimiter::run(true);

if (Froxlor::isDatabaseVersion('202303150')) {
	Update::showUpdateStep("Adding new request rate limit settings");
	Settings::AddNew("system.req_limit_per_interval", "60");
	Settings::AddNew("system.req_limit_interval", "60");
	Update::lastStepStatus(0);

	Froxlor::updateToDbVersion('202304260');
}
use Froxlor\Http\RateLimiter;

		RateLimiter::run();
			$this,
	const DBVERSION = '202304260';
<?php

namespace Froxlor\Http;

class RateLimiter
{
	private static int $limit_per_interval = 60;
	private static int $reset_time = 0;

	public static function run(bool $install_mode = false)
	{
		// default interval = 60 sec
		self::$reset_time = time() + 60;

		if (!$install_mode) {
			self::$limit_per_interval = Settings::Get('system.req_limit_per_interval');
			self::$reset_time = time() + Settings::Get('system.req_limit_interval');
		}

		// Get the remaining requests and reset time from the headers
		$remaining = isset($_SESSION['HTTP_X_RATELIMIT_REMAINING']) ? (int)$_SESSION['HTTP_X_RATELIMIT_REMAINING'] : self::$limit_per_interval;
		$reset = isset($_SESSION['HTTP_X_RATELIMIT_RESET']) ? (int)$_SESSION['HTTP_X_RATELIMIT_RESET'] : self::$reset_time;

		// check if reset time is due
		if (time() > $reset) {
			$remaining = self::$limit_per_interval;
			$reset = self::$reset_time;
		}

		// If we've hit the limit, return an error
		if ($remaining <= 0) {
			header('HTTP/1.1 429 Too Many Requests');
			header("Retry-After: $reset");
			exit();
		}

		// Decrement the remaining requests and update the headers
		$remaining--;
		$_SESSION['HTTP_X_RATELIMIT_REMAINING'] = $remaining;
		$_SESSION['HTTP_X_RATELIMIT_RESET'] = $reset;

		header("X-RateLimit-Limit: " . self::$limit_per_interval);
		header("X-RateLimit-Remaining: " . $remaining);
		header("X-RateLimit-Reset: " . $reset);
	}
}
					if ($key == 'password') {
						// special case for passwords (nowdoc)
						$str .= self::tabPrefix($depth, "'{$key}' => <<<'EOT'\n{$value}\nEOT,\n");
					} else {
						$str .= self::tabPrefix($depth, "'{$key}' => '{$value}',\n");
					}
use Froxlor\Http\RateLimiter;
RateLimiter::run();
		'req_limit_per_interval' => [
			'title' => 'Anzahl der HTTP-Anfragen pro Intervall',
			'description' => 'Erlaubte Anzahl von HTTP-Anfragen pro Intervall (siehe unten) auf froxlor, Standard ist "60"',
		],
		'req_limit_interval' => [
			'title' => 'Rate-Limit-Intervall',
			'description' => 'Specify the time in seconds for the number of HTTP requests, default is "60"',
		],
		'req_limit_per_interval' => [
			'title' => 'Number of HTTP requests per interval',
			'description' => 'Limit the number of HTTP requests per interval (see below) to froxlor, default is "60"',
		],
		'req_limit_interval' => [
			'title' => 'Rate-limit interval',
			'description' => 'Zeit in Sekunden für die maximale Anzahl von HTTP-Anfragen, Standard ist "60".',
		],