CVE-2023-45808 (published 2024-04-15T18:15Z)

iTop is an IT service management platform.  When creating or updating an object, extkey values aren't checked to be in the current user silo. In other words, by forging an http request, the user can create objects pointing to out of silo objects (for example a UserRequest in an out of scope Organization). Fixed in iTop 2.7.10, 3.0.4, 3.1.1, and 3.2.0.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/Combodo/iTop
commit_details: repo_url: https://github.com/Combodo/iTop
author: Pierre Goiffon
author_date: 2023-11-15 10:31:00+01:00
msg: N°6458 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: userrightsprofile.class.inc.php
change_type: ModificationType.MODIFY
diff: @@ -829,8 +829,9 @@ class UserRightsProfile extends UserRightsAddOnAPI
 	}
 
 	/**
-	 * Find out which attribute is corresponding the the dimension 'owner org'
-	 * returns null if no such attribute has been found (no filtering should occur)
+	 * @param string $sClass
+	 * @return string|null Find out which attribute is corresponding the dimension 'owner org'
+	 *                   returns null if no such attribute has been found (no filtering should occur)
 	 */
 	public static function GetOwnerOrganizationAttCode($sClass)
 	{

programming_language: PHP
filename: userrightsprofile.db.class.inc.php
change_type: ModificationType.MODIFY
diff: @@ -604,10 +604,10 @@ class UserRightsProfile extends UserRightsAddOnAPI
 	/**
 	 * Read and cache organizations allowed to the given user
 	 *
-	 * @param $oUser
-	 * @param $sClass (not used here but can be used in overloads)
+	 * @param User $oUser
+	 * @param string $sClass (not used here but can be used in overloads)
 	 *
-	 * @return array
+	 * @return array keys of the User allowed org
 	 * @throws \CoreException
 	 * @throws \Exception
 	 */

programming_language: PHP
filename: applicationextension.inc.php
change_type: ModificationType.MODIFY
diff: @@ -1621,6 +1621,8 @@ class RestUtils
 	 *
 	 * @return DBObject The object found
 	 * @throws Exception If the input structure is not valid or it could not find exactly one object
+	 *
+	 * @see DBObject::CheckChangedExtKeysValues() generic method to check that we can access the linked object isn't used in that use case because values can be literal, OQL, friendlyname
 	 */
 	public static function FindObjectFromKey($sClass, $key, $bAllowNullValue = false)
 	{

programming_language: PHP
filename: cmdbabstract.class.inc.php
change_type: ModificationType.MODIFY
diff: @@ -4764,6 +4764,11 @@ EOF
 			);
 			if ($bResult && (!$bPreview))
 			{
+				// doing the check will load multiple times same objects :/
+				// but it shouldn't cost too much on execution time
+				// user can mitigate by selecting less extkeys/lnk to set and/or less objects to update 🤷‍♂️
+				$oObj->CheckChangedExtKeysValues();
+
 				$oObj->DBUpdate();
 			}
 		}

programming_language: PHP
filename: utils.inc.php
change_type: ModificationType.MODIFY
diff: @@ -2340,6 +2340,38 @@ class utils
 		return in_array($sClass, $aHugeClasses);
 	}
 
+	/**
+	 * Helper around the native strlen() PHP method to test a string for null or empty value
+	 *
+	 * @link https://www.php.net/releases/8.1/en.php#deprecations_and_bc_breaks "Passing null to non-nullable internal function parameters is deprecated"
+	 *
+	 * @param string|null $sString
+	 *
+	 * @return bool if string null or empty
+	 * @since 3.0.2 N°5302
+	 * @since 2.7.10 N°6458 add method in the 2.7 branch
+	 */
+	public static function IsNullOrEmptyString(?string $sString): bool
+	{
+		return $sString === null || strlen($sString) === 0;
+	}
+
+	/**
+	 * Helper around the native strlen() PHP method to test a string not null or empty value
+	 *
+	 * @link https://www.php.net/releases/8.1/en.php#deprecations_and_bc_breaks "Passing null to non-nullable internal function parameters is deprecated"
+	 *
+	 * @param string|null $sString
+	 *
+	 * @return bool if string is not null and not empty
+	 * @since 3.0.2 N°5302
+	 * @since 2.7.10 N°6458 add method in the 2.7 branch
+	 */
+	public static function IsNotNullOrEmptyString(?string $sString): bool
+	{
+		return !static::IsNullOrEmptyString($sString);
+	}
+
 	/**
 	 * Check if iTop is in a development environment (VCS vs build number)
 	 *

programming_language: PHP
filename: coreexception.class.inc.php
change_type: ModificationType.MODIFY
diff: @@ -229,12 +229,47 @@ class CoreUnexpectedValue extends CoreException
 {
 }
 
+/**
+ * @since 2.7.10 3.0.4 3.1.1 3.2.0 N°6458 object creation
+ */
+class InvalidExternalKeyValueException extends CoreUnexpectedValue
+{
+	private const ENUM_PARAMS_OBJECT = 'current_object';
+	private const ENUM_PARAMS_ATTCODE = 'attcode';
+	private const ENUM_PARAMS_ATTVALUE = 'attvalue';
+	private const ENUM_PARAMS_USER = 'current_user';
+
+	public function __construct($oObject, $sAttCode, $aContextData = null, $oPrevious = null)
+	{
+		$aContextData[self::ENUM_PARAMS_OBJECT] = get_class($oObject) . '::' . $oObject->GetKey();
+		$aContextData[self::ENUM_PARAMS_ATTCODE] = $sAttCode;
+		$aContextData[self::ENUM_PARAMS_ATTVALUE] = $oObject->Get($sAttCode);
+
+		$oCurrentUser = UserRights::GetUserObject();
+		if (false === is_null($oCurrentUser)) {
+			$aContextData[self::ENUM_PARAMS_USER] = get_class($oCurrentUser) . '::' . $oCurrentUser->GetKey();
+		}
+
+		parent::__construct('Attribute pointing to an object that is either non existing or not readable by the current user', $aContextData, '', $oPrevious);
+	}
+
+	public function GetAttCode(): string
+	{
+		return $this->getContextData()[self::ENUM_PARAMS_ATTCODE];
+	}
+
+	public function GetAttValue(): string
+	{
+		return $this->getContextData()[self::ENUM_PARAMS_ATTVALUE];
+	}
+}
+
 class SecurityException extends CoreException
 {
 }
 
 /**
- * Throwned when querying on an object that exists in the database but is archived
+ * Thrown when querying on an object that exists in the database but is archived
  *
  * @see N.1108
  * @since 2.5.1

programming_language: PHP
filename: dbobject.class.php
change_type: ModificationType.MODIFY
diff: @@ -2235,6 +2235,83 @@ abstract class DBObject implements iDisplay
 		return array($this->m_bCheckStatus, $this->m_aCheckIssues, $this->m_bSecurityIssue);
 	}
 
+	/**
+	 * Checks for extkey attributes values. This will throw exception on non-existing as well as non-accessible objects (silo, scopes).
+	 * That's why the test is done for all users including Administrators
+	 *
+	 * Note that due to perf issues, this isn't called directly by the ORM, but has to be called by consumers when possible.
+	 *
+	 * @param callable(string, string):bool|null $oIsObjectLoadableCallback Override to check if object is accessible.
+	 *                          Parameters are object class and key
+	 *                          Return value should be false if cannot access object, true otherwise
+	 * @return void
+	 *
+	 * @throws ArchivedObjectException
+	 * @throws CoreException if cannot get object attdef list
+	 * @throws CoreUnexpectedValue
+	 * @throws InvalidExternalKeyValueException
+	 * @throws MySQLException
+	 * @throws SecurityException if one extkey is pointing to an invalid value
+	 *
+	 * @link https://github.com/Combodo/iTop/security/advisories/GHSA-245j-66p9-pwmh
+	 * @since 2.7.10 3.0.4 3.1.1 3.2.0 N°6458
+	 *
+	 * @see \RestUtils::FindObjectFromKey for the same check in the REST endpoint
+	 */
+	final public function CheckChangedExtKeysValues(callable $oIsObjectLoadableCallback = null)
+	{
+		if (is_null($oIsObjectLoadableCallback)) {
+			$oIsObjectLoadableCallback = function ($sClass, $sId) {
+				$oRemoteObject = MetaModel::GetObject($sClass, $sId, false);
+				if (is_null($oRemoteObject)) {
+					return false;
+				}
+				return true;
+			};
+		}
+
+		$aChanges = $this->ListChanges();
+		$aAttCodesChanged = array_keys($aChanges);
+		foreach ($aAttCodesChanged as $sAttDefCode) {
+			$oAttDef = MetaModel::GetAttributeDef(get_class($this), $sAttDefCode);
+
+			if ($oAttDef instanceof AttributeLinkedSetIndirect) {
+				/** @var ormLinkSet $oOrmSet */
+				$oOrmSet = $this->Get($sAttDefCode);
+				while ($oLnk = $oOrmSet->Fetch()) {
+					$oLnk->CheckChangedExtKeysValues($oIsObjectLoadableCallback);
+				}
+				continue;
+			}
+
+			/** @noinspection PhpConditionCheckedByNextConditionInspection */
+			/** @noinspection NotOptimalIfConditionsInspection */
+			if (($oAttDef instanceof AttributeHierarchicalKey) || ($oAttDef instanceof AttributeExternalKey)) {
+				$sRemoteObjectClass = $oAttDef->GetTargetClass();
+				$sRemoteObjectKey = $this->Get($sAttDefCode);
+			} else if ($oAttDef instanceof AttributeObjectKey) {
+				$sRemoteObjectClassAttCode = $oAttDef->Get('class_attcode');
+				$sRemoteObjectClass = $this->Get($sRemoteObjectClassAttCode);
+				$sRemoteObjectKey = $this->Get($sAttDefCode);
+			} else {
+				continue;
+			}
+
+			/** @noinspection NotOptimalIfConditionsInspection */
+			/** @noinspection TypeUnsafeComparisonInspection */
+			if (utils::IsNullOrEmptyString($sRemoteObjectClass)
+				|| utils::IsNullOrEmptyString($sRemoteObjectKey)
+				|| ($sRemoteObjectKey == 0) // non-strict comparison as we might have bad surprises
+			) {
+				continue;
+			}
+
+			if (false === $oIsObjectLoadableCallback($sRemoteObjectClass, $sRemoteObjectKey)) {
+				throw new InvalidExternalKeyValueException($this, $sAttDefCode);
+			}
+		}
+	}
+
 	/**
 	 * Check if it is allowed to delete the existing object from the database
 	 *
@@ -2380,13 +2457,13 @@ abstract class DBObject implements iDisplay
 	 * @api
 	 * @api-advanced
 	 *
-	 * @see  \DBObject::ListPreviousValuesForUpdatedAttributes() to get previous values anywhere in the CRUD stack
-	 * @see https://www.itophub.io/wiki/page?id=latest%3Acustomization%3Asequence_crud iTop CRUD stack documentation
-	 * @return array attname => currentvalue List the attributes that have been changed using {@see DBObject::Set()}.
+	 * @return array attcode => currentvalue List the attributes that have been changed using {@see DBObject::Set()}.
 	 *         Reset during {@see DBObject::DBUpdate()}
 	 * @throws Exception
+	 * @see  \DBObject::ListPreviousValuesForUpdatedAttributes() to get previous values anywhere in the CRUD stack
+	 * @see https://www.itophub.io/wiki/page?id=latest%3Acustomization%3Asequence_crud iTop CRUD stack documentation
 	 * @uses m_aCurrValues
-     */
+	 */
 	public function ListChanges()
 	{
 		if ($this->m_bIsInDB)
@@ -2677,7 +2754,6 @@ abstract class DBObject implements iDisplay
 	 * @throws \Exception
 	 *
 	 * @internal
-	 *
 	 */
 	public function DBInsertNoReload()
 	{
@@ -2957,8 +3033,6 @@ abstract class DBObject implements iDisplay
 	 * Persist an object to the DB, for the first time
 	 *
      * @api
-     * @see DBWrite
-     *
 	 * @return int|null inserted object key
      *
 	 * @throws \ArchivedObjectException
@@ -2968,10 +3042,12 @@ abstract class DBObject implements iDisplay
 	 * @throws \CoreWarning
 	 * @throws \MySQLException
 	 * @throws \OQLException
+	 *
+	 * @see DBWrite
 	 */
 	public function DBInsert()
 	{
-	    $this->DBInsertNoReload();
+		$this->DBInsertNoReload();
 
         if (MetaModel::DBIsReadOnly())
         {
@@ -3070,13 +3146,13 @@ abstract class DBObject implements iDisplay
 	 * Update an object in DB
 	 *
 	 * @api
-	 * @see DBObject::DBWrite()
-	 *
 	 * @return int object key
 	 *
 	 * @throws \CoreException
 	 * @throws \CoreCannotSaveObjectException if CheckToWrite() returns issues
 	 * @throws \Exception
+	 *
+	 * @see DBObject::DBWrite()
 	 */
 	public function DBUpdate()
 	{
@@ -3084,6 +3160,7 @@ abstract class DBObject implements iDisplay
 		{
 			throw new CoreException("DBUpdate: could not update a newly created object, please call DBInsert instead");
 		}
+
 		// Protect against reentrance (e.g. cascading the update of ticket logs)
 		static $aUpdateReentrance = array();
 		$sKey = get_class($this).'::'.$this->GetKey();
@@ -3415,13 +3492,18 @@ abstract class DBObject implements iDisplay
 
 	/**
 	 * Make the current changes persistent - clever wrapper for Insert or Update
-     *
-     * @api
+	 *
+	 * @api
 	 *
 	 * @return int
-     *
-	 * @throws \CoreCannotSaveObjectException
-	 * @throws \CoreException
+	 *
+	 * @throws ArchivedObjectException
+	 * @throws CoreCannotSaveObjectException
+	 * @throws CoreException
+	 * @throws CoreUnexpectedValue
+	 * @throws CoreWarning
+	 * @throws MySQLException
+	 * @throws OQLException
 	 */
 	public function DBWrite()
 	{

programming_language: PHP
filename: metamodel.class.php
change_type: ModificationType.MODIFY
diff: @@ -1213,8 +1213,10 @@ abstract class MetaModel
 	 *
 	 * @return AttributeDefinition[]
 	 * @throws \CoreException
+	 *
+	 * @see GetAttributesList for attcode list
 	 */
-	final static public function ListAttributeDefs($sClass)
+	final public static function ListAttributeDefs($sClass)
 	{
 		self::_check_subclass($sClass);
 		return self::$m_aAttribDefs[$sClass];
@@ -1223,8 +1225,10 @@ abstract class MetaModel
 	/**
 	 * @param string $sClass
 	 *
-	 * @return array
+	 * @return string[] list of attcodes
 	 * @throws \CoreException
+	 *
+	 * @see ListAttributeDefs to get AttributeDefinition array instead
 	 */
 	final public static function GetAttributesList($sClass)
 	{

programming_language: PHP
filename: userrights.class.inc.php
change_type: ModificationType.MODIFY
diff: @@ -817,6 +817,9 @@ class UserRights
 		}
 	}
 
+	/**
+	 * @return string connected {@see User} login field value, otherwise empty string
+	 */
 	public static function GetUser()
 	{
 		if (is_null(self::$m_oUser))
@@ -829,7 +832,9 @@ class UserRights
 		}
 	}
 
-	/** User */
+	/**
+	 * @return User|null
+	 */
 	public static function GetUserObject()
 	{
 		if (is_null(self::$m_oUser))
@@ -1029,7 +1034,7 @@ class UserRights
 
 	/**
 	 * @param string $sClass
-	 * @param int $iActionCode
+	 * @param int $iActionCode see UR_ACTION_* constants
 	 * @param DBObjectSet $oInstanceSet
 	 * @param User $oUser
 	 * @return int (UR_ALLOWED_YES|UR_ALLOWED_NO|UR_ALLOWED_DEPENDS)

programming_language: PHP
filename: ObjectFormManager.php
change_type: ModificationType.MODIFY
diff: @@ -31,6 +31,7 @@ use Combodo\iTop\Form\Field\LabelField;
 use Combodo\iTop\Form\Form;
 use Combodo\iTop\Form\FormManager;
 use Combodo\iTop\Portal\Helper\ApplicationHelper;
+use Combodo\iTop\Portal\Helper\SecurityHelper;
 use CoreCannotSaveObjectException;
 use DBObject;
 use DBObjectSearch;
@@ -41,6 +42,7 @@ use DOMDocument;
 use DOMXPath;
 use Exception;
 use InlineImage;
+use InvalidExternalKeyValueException;
 use IssueLog;
 use MetaModel;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -48,6 +50,7 @@ use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use UserRights;
 use utils;
+use const UR_ACTION_READ;
 
 /**
  * Description of ObjectFormManager
@@ -1135,8 +1138,11 @@ class ObjectFormManager extends FormManager
 			$bWasModified = $this->oObject->IsModified();
 			$bActivateTriggers = (!$bIsNew && $bWasModified);
 
+			/** @var SecurityHelper $oSecurityHelper */
+			$oSecurityHelper = $this->oContainer->get('security_helper');
+
 			// Forcing allowed writing on the object if necessary. This is used in some particular cases.
-			$bAllowWrite = $this->oContainer->get('security_helper')->IsActionAllowed($bIsNew ? UR_ACTION_CREATE : UR_ACTION_MODIFY, $sObjectClass, $this->oObject->GetKey());
+			$bAllowWrite = $oSecurityHelper->IsActionAllowed($bIsNew ? UR_ACTION_CREATE : UR_ACTION_MODIFY, $sObjectClass, $this->oObject->GetKey());
 			if ($bAllowWrite) {
 				$this->oObject->AllowWrite(true);
 			}
@@ -1144,12 +1150,15 @@ class ObjectFormManager extends FormManager
 			// Writing object to DB
 			try
 			{
+				$this->oObject->CheckChangedExtKeysValues(function ($sClass, $sId) use ($oSecurityHelper): bool {
+					return $oSecurityHelper->IsActionAllowed(UR_ACTION_READ, $sClass, $sId);
+				});
 				$this->oObject->DBWrite();
-			}
-			catch (CoreCannotSaveObjectException $e) {
+			} catch (CoreCannotSaveObjectException $e) {
 				throw new Exception($e->getHtmlMessage());
-			}
-			catch (Exception $e) {
+			} catch (InvalidExternalKeyValueException $e) {
+				throw new Exception($e->getIssue());
+			} catch (Exception $e) {
 				if ($bIsNew) {
 					throw new Exception(Dict::S('Portal:Error:ObjectCannotBeCreated'));
 				}

programming_language: PHP
filename: UI.php
change_type: ModificationType.MODIFY
diff: @@ -1002,6 +1002,7 @@ HTML
 						{
 							throw new CoreCannotSaveObjectException(array('id' => $oObj->GetKey(), 'class' => $sClass, 'issues' => $aErrors));
 						}
+						$oObj->CheckChangedExtKeysValues();
 						// Transactions are now handled in DBUpdate
 						$oObj->DBUpdate();
 						$sMessage = Dict::Format('UI:Class_Object_Updated', MetaModel::GetName(get_class($oObj)), $oObj->GetName());
@@ -1233,6 +1234,7 @@ HTML
 					throw new CoreCannotSaveObjectException(array('id' => $oObj->GetKey(), 'class' => $sClass, 'issues' => $aErrors));
 				}
 
+				$oObj->CheckChangedExtKeysValues();
 				$oObj->DBInsertNoReload();// No need to reload
 
                 IssueLog::Trace('Object created', $sClass, array(

programming_language: PHP
filename: README.md
change_type: ModificationType.MODIFY
diff: @@ -4,4 +4,14 @@
 - Covers an iTop PHP class or method?
   - Most likely in "unitary-tests".
 - Covers the consistency of some data through the app?
-  - Most likely in "integration-tests".
\ No newline at end of file
+  - Most likely in "integration-tests".
+
+## Tips
+
+### Measure the time spent in a test
+
+Simply cut'n paste the following line at several places within the test function:
+
+```php
+if (isset($fStarted)) {echo 'L'.__LINE__.': '.round(microtime(true) - $fStarted, 3)."\n";} $fStarted = microtime(true);
+```

programming_language: Markdown
filename: ItopDataTestCase.php
change_type: ModificationType.MODIFY
diff: @@ -15,7 +15,6 @@ namespace Combodo\iTop\Test\UnitTest;
 
 use ArchivedObjectException;
 use CMDBSource;
-use Config;
 use Contact;
 use DBObject;
 use DBObjectSet;
@@ -30,7 +29,6 @@ use lnkFunctionalCIToTicket;
 use MetaModel;
 use Person;
 use Server;
-use SetupUtils;
 use TagSetFieldData;
 use Ticket;
 use URP_UserProfile;
@@ -479,6 +477,35 @@ abstract class ItopDataTestCase extends ItopTestCase
 		return $oUser;
 	}
 
+	/**
+	 * @param string $sLogin
+	 * @param int $iProfileId
+	 *
+	 * @return \UserLocal
+	 * @throws Exception
+	 */
+	protected function CreateContactlessUser($sLogin, $iProfileId, $sPassword = null)
+	{
+		if (empty($sPassword)) {
+			$sPassword = $sLogin;
+		}
+
+		$oUserProfile = new URP_UserProfile();
+		$oUserProfile->Set('profileid', $iProfileId);
+		$oUserProfile->Set('reason', 'UNIT Tests');
+		$oSet = DBObjectSet::FromObject($oUserProfile);
+		/** @var \UserLocal $oUser */
+		$oUser = $this->createObject('UserLocal', array(
+			'login' => $sLogin,
+			'password' => $sPassword,
+			'language' => 'EN US',
+			'profile_list' => $oSet,
+		));
+		$this->debug("Created {$oUser->GetName()} ({$oUser->GetKey()})");
+
+		return $oUser;
+	}
+
 	/**
 	 * @param \DBObject $oUser
 	 * @param int $iProfileId
@@ -658,7 +685,7 @@ abstract class ItopDataTestCase extends ItopTestCase
 	 * @return array
 	 * @throws Exception
 	 */
-	protected function AddCIToTicket($oCI, $oTicket, $sImpactCode)
+	protected function AddCIToTicket($oCI, $oTicket, $sImpactCode = 'manual')
 	{
 		$oNewLink = new lnkFunctionalCIToTicket();
 		$oNewLink->Set('functionalci_id', $oCI->GetKey());

programming_language: PHP
filename: DBObjectTest.php
change_type: ModificationType.MODIFY
diff: @@ -17,18 +17,20 @@
 //   along with iTop. If not, see <http://www.gnu.org/licenses/>
 //
 
-/**
- * Created by PhpStorm.
- * User: Eric
- * Date: 02/10/2017
- * Time: 13:58
- */
-
 namespace Combodo\iTop\Test\UnitTest\Core;
 
+use Attachment;
 use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
 use DBObject;
+use InvalidExternalKeyValueException;
+use lnkPersonToTeam;
 use MetaModel;
+use Organization;
+use Person;
+use Team;
+use User;
+use UserRights;
+use utils;
 
 
 /**
@@ -39,6 +41,7 @@ use MetaModel;
 class DBObjectTest extends ItopDataTestCase
 {
 	const CREATE_TEST_ORG = true;
+	const INVALID_OBJECT_KEY = 123456789;
 
 	protected function setUp(): void
 	{
@@ -121,4 +124,250 @@ class DBObjectTest extends ItopDataTestCase
 		$this->debug("ERROR: N°4967 - 'Previous Values For Updated Attributes' not updated if DBUpdate is called without modifying the object");
 		//$this->assertCount(0, $oOrg->ListPreviousValuesForUpdatedAttributes());
 	}
+
+	private function GetAlwaysTrueCallback(): callable
+	{
+		return static function () {
+			return true;
+		};
+	}
+
+	private function GetAlwaysFalseCallback(): callable
+	{
+		return static function () {
+			return false;
+		};
+	}
+
+	/**
+	 * @covers DBObject::CheckChangedExtKeysValues()
+	 */
+	public function testCheckExtKeysSiloOnAttributeExternalKey()
+	{
+		//--- Preparing data...
+		$oAlwaysTrueCallback = $this->GetAlwaysTrueCallback();
+		$oAlwaysFalseCallback = $this->GetAlwaysFalseCallback();
+
+		/** @var Organization $oDemoOrg */
+		$oDemoOrg = MetaModel::GetObjectByName(Organization::class, 'Demo');
+		/** @var Organization $oMyCompanyOrg */
+		$oMyCompanyOrg = MetaModel::GetObjectByName(Organization::class, 'My Company/Department');
+
+		/** @var Person $oPersonOfDemoOrg */
+		$oPersonOfDemoOrg = MetaModel::GetObjectByName(Person::class, 'Agatha Christie');
+		/** @var Person $oPersonOfMyCompanyOrg */
+		$oPersonOfMyCompanyOrg = MetaModel::GetObjectByName(Person::class, 'My first name My last name');
+
+		$sConfigurationManagerProfileId = 3; // Access to Person objects
+		$oUserWithAllowedOrgs = $this->CreateDemoOrgUser($oDemoOrg, $sConfigurationManagerProfileId);
+
+		$oAdminUser = MetaModel::GetObjectByName(User::class, 'admin', false);
+		if (is_null($oAdminUser)) {
+			$oAdminUser = $this->CreateUser('admin', 1);
+		}
+
+		/** @var Person $oPersonObject */
+		$oPersonObject = $this->CreatePerson(0, $oMyCompanyOrg->GetKey());
+
+		//--- Now we can do some tests !
+		UserRights::Login($oUserWithAllowedOrgs->Get('login'));
+		try {
+			$oPersonObject->CheckChangedExtKeysValues();
+		} catch (InvalidExternalKeyValueException $eCannotSave) {
+			$this->fail('Should skip external keys already written in Database');
+		}
+
+		$oPersonObject->Set('manager_id', $oPersonOfDemoOrg->GetKey());
+		try {
+			$oPersonObject->CheckChangedExtKeysValues();
+		} catch (InvalidExternalKeyValueException $eCannotSave) {
+			$this->fail('Should allow objects in the same org as the current user');
+		}
+
+		try {
+			$oPersonObject->CheckChangedExtKeysValues($oAlwaysFalseCallback);
+			$this->fail('Should consider the callback returning "false"');
+		} catch (InvalidExternalKeyValueException $eCannotSave) {
+			// Ok, the exception was expected
+		}
+
+		$oPersonObject->Set('manager_id', $oPersonOfMyCompanyOrg->GetKey());
+		try {
+			$oPersonObject->CheckChangedExtKeysValues();
+			$this->fail('Should not allow objects not being in the allowed orgs of the current user');
+		} catch (InvalidExternalKeyValueException $eCannotSave) {
+			$this->assertEquals('manager_id', $eCannotSave->GetAttCode(), 'Should report the wrong external key attcode');
+			$this->assertEquals($oMyCompanyOrg->GetKey(), $eCannotSave->GetAttValue(), 'Should report the unauthorized external key value');
+		}
+
+		try {
+			$oPersonObject->CheckChangedExtKeysValues($oAlwaysTrueCallback);
+		} catch (InvalidExternalKeyValueException $eCannotSave) {
+			$this->fail('Should consider the callback returning "true"');
+		}
+
+		// ugly hack to remove cached SQL queries :(
+		//FIXME In 3.0+ this won't be necessary anymore thanks to UserRights::Logoff
+		$this->SetNonPublicStaticProperty(MetaModel::class, 'aQueryCacheGetObject', []);
+
+		UserRights::Login($oAdminUser->Get('login'));
+		$oPersonObject->CheckChangedExtKeysValues();
+		$this->assertTrue(true, 'Admin user can create objects in any org');
+	}
+
+	/**
+	 * @covers DBObject::CheckChangedExtKeysValues()
+	 */
+	public function testCheckExtKeysOnAttributeLinkedSetIndirect()
+	{
+		//--- Preparing data...
+		/** @var Organization $oDemoOrg */
+		$oDemoOrg = MetaModel::GetObjectByName(Organization::class, 'Demo');
+		/** @var Person $oPersonOnItDepartmentOrg */
+		$oPersonOnItDepartmentOrg = MetaModel::GetObjectByName(Person::class, 'Anna Gavalda');
+		/** @var Person $oPersonOnDemoOrg */
+		$oPersonOnDemoOrg = MetaModel::GetObjectByName(Person::class, 'Claude Monet');
+
+		$sConfigManagerProfileId = 3; // access to Team and Contact objects
+		$oUserWithAllowedOrgs = $this->CreateDemoOrgUser($oDemoOrg, $sConfigManagerProfileId);
+
+		//--- Now we can do some tests !
+		UserRights::Login($oUserWithAllowedOrgs->Get('login'));
+
+		$oTeam = MetaModel::NewObject(Team::class, [
+			'name' => 'The A Team',
+			'org_id' => $oDemoOrg->GetKey()
+		]);
+
+		// Part 1 - Test with an invalid id (non-existing object)
+		//
+		$oPersonLinks = \DBObjectSet::FromScratch(lnkPersonToTeam::class);
+		$oPersonLinks->AddObject(MetaModel::NewObject(lnkPersonToTeam::class, [
+			'person_id' => self::INVALID_OBJECT_KEY,
+		]));
+		$oTeam->Set('persons_list', $oPersonLinks);
+
+		try {
+			$oTeam->CheckChangedExtKeysValues();
+			$this->fail('An unknown object should be detected as invalid');
+		} catch (InvalidExternalKeyValueException $e) {
+			// we are getting the exception on the lnk class
+			// In consequence attcode is `lnkPersonToTeam.person_id` instead of `Team.persons_list`
+			$this->assertEquals('person_id', $e->GetAttCode(), 'The reported attcode should be the external key on the link');
+			$this->assertEquals(self::INVALID_OBJECT_KEY, $e->GetAttValue(), 'The reported value should be the external key on the link');
+		}
+
+		try {
+			$oTeam->CheckChangedExtKeysValues($this->GetAlwaysTrueCallback());
+		} catch (InvalidExternalKeyValueException $e) {
+			$this->fail('Should have no error when callback returns true');
+		}
+
+		// Part 2 - Test with an allowed object
+		//
+		$oPersonLinks = \DBObjectSet::FromScratch(lnkPersonToTeam::class);
+		$oPersonLinks->AddObject(MetaModel::NewObject(lnkPersonToTeam::class, [
+			'person_id' => $oPersonOnDemoOrg->GetKey(),
+		]));
+		$oTeam->Set('persons_list', $oPersonLinks);
+
+		try {
+			$oTeam->CheckChangedExtKeysValues();
+		} catch (InvalidExternalKeyValueException $e) {
+			$this->fail('An authorized object should be detected as valid');
+		}
+
+		try {
+			$oTeam->CheckChangedExtKeysValues($this->GetAlwaysFalseCallback());
+			$this->fail('Should cascade the callback result when it is "false"');
+		} catch (InvalidExternalKeyValueException $e) {
+			// Ok, the exception was expected
+		}
+
+		// Part 3 - Test with a not allowed object
+		//
+		$oPersonLinks = \DBObjectSet::FromScratch(lnkPersonToTeam::class);
+		$oPersonLinks->AddObject(MetaModel::NewObject(lnkPersonToTeam::class, [
+			'person_id' => $oPersonOnItDepartmentOrg->GetKey(),
+		]));
+		$oTeam->Set('persons_list', $oPersonLinks);
+
+		try {
+			$oTeam->CheckChangedExtKeysValues();
+			$this->fail('An unauthorized object should be detected as invalid');
+		}
+		catch (InvalidExternalKeyValueException $e) {
+			// Ok, the exception was expected
+		}
+
+		try {
+			$oTeam->CheckChangedExtKeysValues($this->GetAlwaysTrueCallback());
+		} catch (InvalidExternalKeyValueException $e) {
+			$this->fail('Should cascade the callback result when it is "true"');
+		}
+
+		$oTeam->DBInsert(); // persisting invalid value and resets the object changed values
+		try {
+			$oTeam->CheckChangedExtKeysValues();
+		}
+		catch (InvalidExternalKeyValueException $e) {
+			$this->fail('An unauthorized value should be ignored when it is not being modified');
+		}
+	}
+
+	/**
+	 * @covers DBObject::CheckChangedExtKeysValues()
+	 */
+	public function testCheckExtKeysSiloOnAttributeObjectKey()
+	{
+		//--- Preparing data...
+		/** @var Organization $oDemoOrg */
+		$oDemoOrg = MetaModel::GetObjectByName(Organization::class, 'Demo');
+		/** @var Person $oPersonOnItDepartmentOrg */
+		$oPersonOnItDepartmentOrg = MetaModel::GetObjectByName(Person::class, 'Anna Gavalda');
+		/** @var Person $oPersonOnDemoOrg */
+		$oPersonOnDemoOrg = MetaModel::GetObjectByName(Person::class, 'Claude Monet');
+
+		$sConfigManagerProfileId = 3; // access to Team and Contact objects
+		$oUserWithAllowedOrgs = $this->CreateDemoOrgUser($oDemoOrg, $sConfigManagerProfileId);
+
+		//--- Now we can do some tests !
+		UserRights::Login($oUserWithAllowedOrgs->Get('login'));
+
+		$oAttachment = MetaModel::NewObject(Attachment::class, [
+			'item_class' => Person::class,
+			'item_id' => $oPersonOnDemoOrg->GetKey(),
+		]);
+		try {
+			$oAttachment->CheckChangedExtKeysValues();
+		} catch (InvalidExternalKeyValueException $e) {
+			$this->fail('Should be allowed to create an attachment pointing to a ticket in the allowed org list');
+		}
+
+		$oAttachment = MetaModel::NewObject(Attachment::class, [
+			'item_class' => Person::class,
+			'item_id' => $oPersonOnItDepartmentOrg->GetKey(),
+		]);
+		try {
+			$oAttachment->CheckChangedExtKeysValues();
+			$this->fail('There should be an error on attachment pointing to a non allowed org object');
+		} catch (InvalidExternalKeyValueException $e) {
+			$this->assertEquals('item_id', $e->GetAttCode(), 'Should report the object key attribute');
+			$this->assertEquals($oPersonOnItDepartmentOrg->GetKey(), $e->GetAttValue(), 'Should report the object key value');
+		}
+	}
+
+	private function CreateDemoOrgUser(Organization $oDemoOrg, string $sProfileId): User
+	{
+		utils::GetConfig()->SetModuleSetting('authent-local', 'password_validation.pattern', '');
+		$oUserWithAllowedOrgs = $this->CreateContactlessUser('demo_test_' . __CLASS__, $sProfileId);
+		/** @var \URP_UserOrg $oUserOrg */
+		$oUserOrg = \MetaModel::NewObject('URP_UserOrg', ['allowed_org_id' => $oDemoOrg->GetKey(),]);
+		$oAllowedOrgList = $oUserWithAllowedOrgs->Get('allowed_org_list');
+		$oAllowedOrgList->AddItem($oUserOrg);
+		$oUserWithAllowedOrgs->Set('allowed_org_list', $oAllowedOrgList);
+		$oUserWithAllowedOrgs->DBWrite();
+
+		return $oUserWithAllowedOrgs;
+	}
 }

programming_language: PHP
repo_url: https://github.com/Combodo/iTop
commit_details: repo_url: https://github.com/Combodo/iTop
author: Pierre Goiffon
author_date: 2023-11-16 09:23:19+01:00
msg: N°6458 Fix portal errors
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: ObjectFormManager.php
change_type: ModificationType.MODIFY
diff: @@ -32,7 +32,6 @@ use Combodo\iTop\Form\Form;
 use Combodo\iTop\Form\FormManager;
 use Combodo\iTop\Portal\Helper\ApplicationHelper;
 use Combodo\iTop\Portal\Helper\ObjectFormHandlerHelper;
-use Combodo\iTop\Portal\Helper\SecurityHelper;
 use CoreCannotSaveObjectException;
 use DBObject;
 use DBObjectSearch;
@@ -1136,11 +1135,10 @@ class ObjectFormManager extends FormManager
 			$bWasModified = $this->oObject->IsModified();
 			$bActivateTriggers = (!$bIsNew && $bWasModified);
 
-			/** @var SecurityHelper $oSecurityHelper */
-			$oSecurityHelper = $this->oContainer->get('security_helper');
+			$oSecurityHelper = $this->oFormHandlerHelper->getSecurityHelper();
 
 			// Forcing allowed writing on the object if necessary. This is used in some particular cases.
-			$bAllowWrite = $this->oFormHandlerHelper->getSecurityHelper()->IsActionAllowed($bIsNew ? UR_ACTION_CREATE : UR_ACTION_MODIFY, $sObjectClass, $this->oObject->GetKey());
+			$bAllowWrite = $oSecurityHelper->IsActionAllowed($bIsNew ? UR_ACTION_CREATE : UR_ACTION_MODIFY, $sObjectClass, $this->oObject->GetKey());
 			if ($bAllowWrite) {
 				$this->oObject->AllowWrite(true);
 			}

programming_language: PHP