CVE-2021-41245 (published 2022-04-05T15:15Z)

Combodo iTop is a web based IT Service Management tool. In versions prior to 2.7.6 and 3.0.0, CSRF tokens generated by `privUITransactionFile` aren't properly checked. Versions 2.7.6 and 3.0.0 contain a patch for this issue. As a workaround, use the session implementation by adding in the iTop config file.

Severity: MEDIUM
CVSS v2 base score: 5.8 (AV:N/AC:M/Au:N/C:N/I:P/A:P)
CVSS v3 base score: 8.1, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H)

Weakness information:
cwe_id: CWE-352
cwe_name: Cross-Site Request Forgery (CSRF)
description: The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
extended_description: When a web server is designed to receive a request from a client without any mechanism for verifying that it was intentionally sent, then it might be possible for an attacker to trick a client into making an unintentional request to the web server which will be treated as an authentic request. This can be done via a URL, image load, XMLHttpRequest, etc. and can result in exposure of data or unintended code execution.
         

Fix information:
repo_url: https://github.com/Combodo/iTop
commit_details: repo_url: https://github.com/Combodo/iTop
author: Pierre Goiffon
author_date: 2021-10-21 11:55:21+02:00
msg: N°4289 Security hardening
repository: repo_url: https://github.com/Combodo/iTop
repo_name: Combodo/iTop
description: A simple, web based IT Service Management tool 
repo_language: PHP
owner: Combodo
file_changes: filename: transaction.class.inc.php
change_type: ModificationType.MODIFY
diff: @@ -193,10 +193,32 @@ class privUITransactionSession
  */
 class privUITransactionFile
 {
+	/**
+	 * @return int
+	 * @throws \SecurityException if no connected user
+	 *
+	 * @since 2.6.5 2.7.6 3.0.0 N°4289 method creation
+	 */
+	private static function GetCurrentUserId() {
+		$iCurrentUserId = UserRights::GetConnectedUserId();
+		if ('' === $iCurrentUserId) {
+			throw new SecurityException('Cannot creation transaction_id when no user logged');
+		}
+
+		return $iCurrentUserId;
+	}
+
 	/**
 	 * Create a new transaction id, store it in the session and return its id
+	 *
 	 * @param void
+	 *
 	 * @return int The identifier of the new transaction
+	 *
+	 * @throws \SecurityException
+	 * @throws \Exception
+	 *
+	 * @since 2.6.5 2.7.6 3.0.0 security hardening + throws SecurityException if no user logged
 	 */
 	public static function GetNewTransactionId()
 	{
@@ -212,24 +234,36 @@ class privUITransactionFile
 				throw new Exception('Failed to create the directory "'.APPROOT.'data/transactions". Ajust the rights on the parent directory or let an administrator create the transactions directory and give the web sever enough rights to write into it.');
 			}
 		}
+
 		if (!is_writable(APPROOT.'data/transactions'))
 		{
 			throw new Exception('The directory "'.APPROOT.'data/transactions" must be writable to the application.');
 		}
+
+		$iCurrentUserId = static::GetCurrentUserId();
+
 		self::CleanupOldTransactions();
-		$id = basename(tempnam(APPROOT.'data/transactions', static::GetUserPrefix()));
-		self::Info('GetNewTransactionId: Created transaction: '.$id);
 
-		return (string)$id;
+		$sTransactionIdFullPath = tempnam(APPROOT.'data/transactions', static::GetUserPrefix());
+		file_put_contents($sTransactionIdFullPath, $iCurrentUserId, LOCK_EX);
+
+		$sTransactionIdFileName = basename($sTransactionIdFullPath);
+		self::Info('GetNewTransactionId: Created transaction: '.$sTransactionIdFileName);
+
+		return $sTransactionIdFileName;
 	}
 
 	/**
 	 * Check whether a transaction is valid or not and (optionally) remove the valid transaction from
 	 * the session so that another call to IsTransactionValid for the same transaction id
 	 * will return false
+	 *
 	 * @param int $id Identifier of the transaction, as returned by GetNewTransactionId
 	 * @param bool $bRemoveTransaction True if the transaction must be removed
+	 *
 	 * @return bool True if the transaction is valid, false otherwise
+	 *
+	 * @since 2.6.5 2.7.6 3.0.0 N°4289 security hardening
 	 */
 	public static function IsTransactionValid($id, $bRemoveTransaction = true)
 	{
@@ -243,25 +277,32 @@ class privUITransactionFile
 
 		clearstatcache(true, $sFilepath);
 		$bResult = file_exists($sFilepath);
-		if ($bResult)
+
+		if (false === $bResult) {
+			self::Info("IsTransactionValid: Transaction '$id' not found. Pending transactions:\n".implode("\n", self::GetPendingTransactions()));
+			return false;
+		}
+
+		$iCurrentUserId = static::GetCurrentUserId();
+		$sTransactionIdUserId = file_get_contents($sFilepath);
+		if ($iCurrentUserId != $sTransactionIdUserId) {
+			self::Info("IsTransactionValid: Transaction '$id' not existing for current user. Pending transactions:\n".implode("\n", self::GetPendingTransactions()));
+			return false;
+		}
+
+		if ($bRemoveTransaction)
 		{
-			if ($bRemoveTransaction)
+			$bResult = @unlink($sFilepath);
+			if (!$bResult)
 			{
-				$bResult = @unlink($sFilepath);
-				if (!$bResult)
-				{
-					self::Error('IsTransactionValid: FAILED to remove transaction '.$id);
-				}
-				else
-				{
-					self::Info('IsTransactionValid: OK. Removed transaction: '.$id);
-				}
+				self::Error('IsTransactionValid: FAILED to remove transaction '.$id);
+			}
+			else
+			{
+				self::Info('IsTransactionValid: OK. Removed transaction: '.$id);
 			}
 		}
-		else
-		{
-			self::Info("IsTransactionValid: Transaction '$id' not found. Pending transactions for this user:\n".implode("\n", self::GetPendingTransactions()));
-		}
+
 		return $bResult;
 	}
 
@@ -269,26 +310,20 @@ class privUITransactionFile
 	 * Removes the transaction specified by its id
 	 * @param int $id The Identifier (as returned by GetNewTransactionId) of the transaction to be removed.
 	 * @return bool true if the token can be removed
+	 *
+	 * @since 2.6.5 2.7.6 3.0.0 N°4289 security hardening
 	 */
 	public static function RemoveTransaction($id)
 	{
-		$sFilepath = APPROOT.'data/transactions/'.$id;
-		clearstatcache(true, $sFilepath);
-		if (!file_exists($sFilepath)) {
-			$bSuccess = false;
-			self::Error("RemoveTransaction: Transaction '$id' not found. Pending transactions for this user:\n"
+		/** @noinspection PhpRedundantOptionalArgumentInspection */
+		$bResult = static::IsTransactionValid($id, true);
+		if (false === $bResult) {
+			self::Error("RemoveTransaction: Transaction '$id' is invalid. Pending transactions:\n"
 				.implode("\n", self::GetPendingTransactions()));
-		} else {
-			$bSuccess = @unlink($sFilepath);
-		}
-
-		if (!$bSuccess) {
-			self::Error('RemoveTransaction: FAILED to remove transaction '.$id);
-		} else {
-			self::Info('RemoveTransaction: OK '.$id);
+			return false;
 		}
 
-		return $bSuccess;
+		return true;
 	}
 
 	/**

programming_language: PHP
filename: userrights.class.inc.php
change_type: ModificationType.MODIFY
diff: @@ -951,6 +951,21 @@ class UserRights
 		return self::$m_oRealUser;
 	}
 
+	/**
+	 * @return int|string ID of the connected user : if impersonate then use {@see m_oRealUser}, else {@see m_oUser}. If no user set then return ''
+	 * @since 2.6.5 2.7.6 3.0.0 N°4289 method creation
+	 */
+	public static function GetConnectedUserId() {
+		if (false === is_null(static::$m_oRealUser)) {
+			return static::$m_oRealUser->GetKey();
+		}
+		if (false === is_null(static::$m_oUser)) {
+			return static::$m_oUser->GetKey();
+		}
+
+		return '';
+	}
+
 	public static function GetRealUserId()
 	{
 		if (is_null(self::$m_oRealUser))
@@ -1211,7 +1226,7 @@ class UserRights
 		elseif ((self::$m_oUser !== null) && ($oUser->GetKey() == self::$m_oUser->GetKey()))
 		{
 			// Data about the current user can be found into the session data
-			if (array_key_exists('profile_list', $_SESSION))
+			if ((false === utils::IsModeCLI()) && array_key_exists('profile_list', $_SESSION))
 			{
 				$aProfiles = $_SESSION['profile_list'];
 			}

programming_language: PHP
filename: privUITransactionFileTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,27 @@
+<?php
+class privUITransactionFileTest extends \Combodo\iTop\Test\UnitTest\ItopDataTestCase
+{
+	const USER_TEST_LOGIN = 'support_test_privUITransaction';
+
+	public function testIsTransactionValid() {
+		$this->CreateUser(static::USER_TEST_LOGIN, 5); // profile:5 is "Support agent"
+
+		// create token in the support user context
+		UserRights::Login(self::USER_TEST_LOGIN);
+		$sTransactionIdUserSupport = privUITransactionFile::GetNewTransactionId();
+		$bResult = privUITransactionFile::IsTransactionValid($sTransactionIdUserSupport, false);
+		$this->assertTrue($bResult, 'Token created by support user must be valid in the support user context');
+
+		// test token in the admin user context
+		UserRights::Login('admin');
+		$bResult = privUITransactionFile::IsTransactionValid($sTransactionIdUserSupport, false);
+		$this->assertFalse($bResult, 'Token created by support user must be invalid in the admin user context');
+		$bResult = privUITransactionFile::RemoveTransaction($sTransactionIdUserSupport);
+		$this->assertFalse($bResult, 'Token created by support user cannot be removed in the admin user context');
+
+		// test other methods in the support user context
+		UserRights::Login(self::USER_TEST_LOGIN);
+		$bResult = privUITransactionFile::RemoveTransaction($sTransactionIdUserSupport);
+		$this->assertTrue($bResult, 'Token created by support user must be removed in the support user context');
+	}
+}
\ No newline at end of file

programming_language: PHP