CVE-2023-6069 (published 2023-11-10T01:15Z)

Improper Link Resolution Before File Access in GitHub repository froxlor/froxlor prior to 2.1.0.\n\n

CVSS v3 base score: 8.8, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-59
cwe_name: Improper Link Resolution Before File Access ('Link Following')
description: The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/froxlor/froxlor
commit_details: repo_url: https://github.com/froxlor/froxlor
author: Michael Kaufmann
author_date: 2023-10-13 10:18:53+02:00
msg: check for symlinks when required to be within customer-homedir

Signed-off-by: Michael Kaufmann <d00p@froxlor.org>
repository: repo_url: https://github.com/froxlor/froxlor
repo_name: froxlor/Froxlor
description: The server administration software for your needs - The official Froxlor development Git repository
repo_language: PHP
owner: froxlor
file_changes: filename: customer_ftp.php
change_type: ModificationType.MODIFY
diff: @@ -59,7 +59,7 @@ if ($page == 'overview' || $page == 'accounts') {
 
 		$actions_links = [];
 		if (CurrentUser::canAddResource('ftps')) {
-			$actions_links = [
+			$actions_links[] = [
 				'href' => $linker->getLink(['section' => 'ftp', 'page' => 'accounts', 'action' => 'add']),
 				'label' => lng('ftp.account_add')
 			];

programming_language: PHP
filename: DirOptions.php
change_type: ModificationType.MODIFY
diff: @@ -93,7 +93,7 @@ class DirOptions extends ApiCommand implements ResourceEntity
 		// validation
 		$path = FileDir::makeCorrectDir(Validate::validate($path, 'path', Validate::REGEX_DIR, '', [], true));
 		$userpath = $path;
-		$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path);
+		$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);
 
 		if (!empty($error404path)) {
 			$error404path = $this->correctErrorDocument($error404path, true);

programming_language: PHP
filename: DirProtections.php
change_type: ModificationType.MODIFY
diff: @@ -84,7 +84,7 @@ class DirProtections extends ApiCommand implements ResourceEntity
 
 		// validation
 		$path = FileDir::makeCorrectDir(Validate::validate($path, 'path', Validate::REGEX_DIR, '', [], true));
-		$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path);
+		$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);
 		$username = Validate::validate($username, 'username', '/^[a-zA-Z0-9][a-zA-Z0-9\-_]+\$?$/', '', [], true);
 		$authname = Validate::validate($authname, 'directory_authname', '/^[a-zA-Z0-9][a-zA-Z0-9\-_ ]+\$?$/', '', [], true);
 		$password = Validate::validate($password, 'password', '', '', [], true);

programming_language: PHP
filename: Ftps.php
change_type: ModificationType.MODIFY
diff: @@ -174,7 +174,7 @@ class Ftps extends ApiCommand implements ResourceEntity
 			} elseif ($username == $password) {
 				Response::standardError('passwordshouldnotbeusername', '', true);
 			} else {
-				$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path);
+				$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);
 				$cryptPassword = Crypt::makeCryptPassword($password, false, true);
 
 				$stmt = Database::prepare("INSERT INTO `" . TABLE_FTP_USERS . "`
@@ -469,7 +469,7 @@ class Ftps extends ApiCommand implements ResourceEntity
 
 		// path update?
 		if ($path != '') {
-			$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path);
+			$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);
 
 			if ($path != $result['homedir']) {
 				$stmt = Database::prepare("UPDATE `" . TABLE_FTP_USERS . "`

programming_language: PHP
filename: SubDomains.php
change_type: ModificationType.MODIFY
diff: @@ -564,9 +564,9 @@ class SubDomains extends ApiCommand implements ResourceEntity
 			// If path is empty or '/' and 'Use domain name as default value for DocumentRoot path' is enabled in settings,
 			// set default path to subdomain or domain name
 			if ((($path == '') || ($path == '/')) && Settings::Get('system.documentroot_use_default_value') == 1) {
-				$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $completedomain);
+				$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $completedomain, $customer['documentroot']);
 			} else {
-				$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path);
+				$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);
 			}
 		} else {
 			// no it's not, create a redirect

programming_language: PHP
filename: Apache.php
change_type: ModificationType.MODIFY
diff: @@ -129,7 +129,7 @@ class Apache extends HttpConfigBase
 				if ($row_ipsandports['ssl'] == '0' && Settings::Get('system.le_froxlor_redirect') == '1') {
 					$is_redirect = true;
 					// check whether froxlor uses Let's Encrypt and not cert is being generated yet
-					// or a renew is ongoing - disable redirect
+					// or a renewal is ongoing - disable redirect
 					if (Settings::Get('system.le_froxlor_enabled') && ($this->froxlorVhostHasLetsEncryptCert() == false || $this->froxlorVhostLetsEncryptNeedsRenew())) {
 						$this->virtualhosts_data[$vhosts_filename] .= '# temp. disabled ssl-redirect due to Let\'s Encrypt certificate generation.' . PHP_EOL;
 						$is_redirect = false;
@@ -1255,7 +1255,7 @@ class Apache extends HttpConfigBase
 					// >=apache-2.4 enabled?
 					if (Settings::Get('system.apache24') == '1') {
 						$mypath_dir = new Directory($row_diroptions['path']);
-						// only create the require all granted if there is not active directory-protection
+						// only create the' require all granted' if there is no active directory-protection
 						// for this path, as this would be the first require and therefore grant all access
 						if ($mypath_dir->isUserProtected() == false) {
 							$this->diroptions_data[$diroptions_filename] .= '  Require all granted' . "\n";

programming_language: PHP
filename: FileDir.php
change_type: ModificationType.MODIFY
diff: @@ -26,10 +26,10 @@
 namespace Froxlor;
 
 use Exception;
-use PDO;
-use RecursiveCallbackFilterIterator;
 use Froxlor\Customer\Customer;
 use Froxlor\Database\Database;
+use PDO;
+use RecursiveCallbackFilterIterator;
 
 class FileDir
 {
@@ -51,11 +51,12 @@ class FileDir
 	public static function mkDirWithCorrectOwnership(
 		string $homeDir,
 		string $dirToCreate,
-		int $uid,
-		int $gid,
-		bool $placeindex = false,
-		bool $allow_notwithinhomedir = false
-	): bool {
+		int    $uid,
+		int    $gid,
+		bool   $placeindex = false,
+		bool   $allow_notwithinhomedir = false
+	): bool
+	{
 		if ($homeDir != '' && $dirToCreate != '') {
 			$homeDir = self::makeCorrectDir($homeDir);
 			$dirToCreate = self::makeCorrectDir($dirToCreate);
@@ -107,15 +108,16 @@ class FileDir
 	}
 
 	/**
-	 * Function which returns a correct dirname, means to add slashes at the beginning and at the end if there weren't
-	 * some
+	 * Returns a correct/secure dirname, means to add slashes at the beginning and at the end if there weren't
+	 * some. If $fixes_homedir is specified,
+	 *
 	 *
 	 * @param string $dir the path to correct
 	 *
 	 * @return string the corrected path
 	 * @throws Exception
 	 */
-	public static function makeCorrectDir(string $dir): string
+	public static function makeCorrectDir(string $dir, string $fixed_homedir = ""): string
 	{
 		if (strlen($dir) > 0) {
 			$dir = trim($dir);
@@ -125,6 +127,30 @@ class FileDir
 			if (substr($dir, 0, 1) != '/') {
 				$dir = '/' . $dir;
 			}
+
+			// if given, check that the target path is within the $fixed_homedir
+			// by checking each folder for being a symlink and whether it targets
+			// the customers homedir or points outside of it
+			if (!empty($fixed_homedir)) {
+				$to_check = explode("/", substr($dir, strlen($fixed_homedir) + 1), -1);
+				$check_dir = substr($fixed_homedir, 0, -1);
+				// Symlink check
+				foreach ($to_check as $sub_dir) {
+					$check_dir .= '/' . $sub_dir;
+					if (is_link($check_dir)) {
+						$original_target = $check_dir;
+						$check_dir = readlink($check_dir);
+						if (substr($check_dir, 0, strlen($fixed_homedir)) != $fixed_homedir) {
+							throw new Exception("Found symlink pointing outside of customer home directory: " . substr($original_target, strlen($fixed_homedir)));
+						}
+					}
+				}
+				// check for the path to be within the given homedir
+				if (substr($dir, 0, strlen($fixed_homedir)) != $fixed_homedir) {
+					throw new Exception("Target path not within the required customer home directory");
+				}
+			}
+
 			return self::makeSecurePath($dir);
 		}
 		throw new Exception("Cannot validate directory in " . __FUNCTION__ . " which is very dangerous.");
@@ -245,9 +271,10 @@ class FileDir
 	public static function storeDefaultIndex(
 		string $loginname,
 		string $destination,
-		$logger = null,
-		bool $force = false
-	) {
+			   $logger = null,
+		bool   $force = false
+	)
+	{
 		if ($force || (int)Settings::Get('system.store_index_file_subs') == 1) {
 			$result_stmt = Database::prepare("
 			SELECT `t`.`value`, `c`.`email` AS `customer_email`, `a`.`email` AS `admin_email`, `c`.`loginname` AS `customer_login`, `a`.`loginname` AS `admin_login`

programming_language: PHP
filename: FtpsTest.php
change_type: ModificationType.MODIFY
diff: @@ -1,9 +1,10 @@
 <?php
-use PHPUnit\Framework\TestCase;
 
 use Froxlor\Api\Commands\Admins;
 use Froxlor\Api\Commands\Customers;
 use Froxlor\Api\Commands\Ftps;
+use Froxlor\Froxlor;
+use PHPUnit\Framework\TestCase;
 
 /**
  *
@@ -164,6 +165,31 @@ class FtpsTest extends TestCase
 		$this->assertEquals($customer_userdata['documentroot'], $result['homedir']);
 	}
 
+	public function testCustomerFtpsAddSymlinkOutsideHomedir()
+	{
+		global $admin_userdata;
+
+		// get customer
+		$json_result = Customers::getLocal($admin_userdata, array(
+			'loginname' => 'test1'
+		))->get();
+		$customer_userdata = json_decode($json_result, true)['data']; //
+
+		$customer_userdata['documentroot'] = sys_get_temp_dir() . '/';
+		@unlink($customer_userdata['documentroot'] . '/frx');
+		symlink(Froxlor::getInstallDir(), $customer_userdata['documentroot'] . '/frx');
+
+		$data = [
+			'ftp_password' => 'h4xXx0r',
+			'path' => '/frx/sub',
+			'ftp_description' => 'testing',
+			'sendinfomail' => TRAVIS_CI == 1 ? 0 : 1
+		];
+
+		$this->expectExceptionMessage('Found symlink pointing outside of customer home directory: /frx');
+		Ftps::getLocal($customer_userdata, $data)->add();
+	}
+
 	public function testCustomerFtpsAddNoMoreResources()
 	{
 		global $admin_userdata;
@@ -178,7 +204,7 @@ class FtpsTest extends TestCase
 
 		$this->expectExceptionCode(406);
 		$this->expectExceptionMessage('No more resources available');
-		$json_result = Ftps::getLocal($customer_userdata)->add();
+		Ftps::getLocal($customer_userdata)->add();
 	}
 
 	public function testAdminFtpsAddCustomerRequired()
@@ -194,7 +220,7 @@ class FtpsTest extends TestCase
 
 		$this->expectExceptionCode(406);
 		$this->expectExceptionMessage('Requested parameter "loginname" is empty where it should not be for "Customers:get"');
-		$json_result = Ftps::getLocal($admin_userdata, $data)->add();
+		Ftps::getLocal($admin_userdata, $data)->add();
 	}
 
 	public function testCustomerFtpsEdit()

programming_language: PHP