CVE-2024-27930: Insufficient Information (NVD-CWE-noinfo)
value: GLPI is a Free Asset and IT Management Software package, Data center management, ITIL Service Desk, licenses tracking and software auditing. An authenticated user can access sensitive fields data from items on which he has read access. This issue has been patched in version 10.0.13.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-18T16:15Z
Repository: https://github.com/glpi-project/glpi
Language: PHP
Files changed: CHANGELOG.md, ajax/cable.php, ajax/dropdownAllItems.php, ajax/socket.php, ajax/visibility.php, src/Dropdown.php, src/Session.php, templates/components/itilobject/actors/field.html.twig, tests/functional/Dropdown.php, tests/functional/Session.php
Commit date: 2024-03-13 08:16:43 +0100
Commit message: Merge pull request from GHSA-82vv-j9pr-qmwq

* Check `displaywith` and `condition` in IDOR tokens

* Restrict AJAX endpoints to central interface

* Better filtering of sensitive fields
Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index 981955aba047..0f9be80d09ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,10 +16,11 @@ The present file will list all changes made to the project; according to the
 ### API changes
 
 #### Added
 
 #### Changes
+- `condition` and `displaywith` parameters must now be added in IDOR token creation data when they are not empty.
 
 #### Deprecated
 
 #### Removed
 
diff --git a/ajax/cable.php b/ajax/cable.php
index 6a9bf6ec6aca..d4587118a39d 100644
--- a/ajax/cable.php
+++ b/ajax/cable.php
@@ -41,11 +41,11 @@ global $CFG_GLPI;
 include('../inc/includes.php');
 
 // Send UTF8 Headers
 header("Content-Type: text/html; charset=UTF-8");
 Html::header_nocache();
-Session::checkLoginUser();
+Session::checkCentralAccess();
 
 $action = $_POST['action'] ?? $_GET["action"];
 
 switch ($action) {
     case 'get_items_from_itemtype':
diff --git a/ajax/dropdownAllItems.php b/ajax/dropdownAllItems.php
index c7e602600cf0..acdb2c10027c 100644
--- a/ajax/dropdownAllItems.php
+++ b/ajax/dropdownAllItems.php
@@ -54,36 +54,42 @@ if ($_POST["idtable"] && class_exists($_POST["idtable"])) {
 
     $rand = $_POST['rand'] ?? mt_rand();
 
     $field_id = Html::cleanId("dropdown_" . $_POST["name"] . $rand);
 
-    $p        = [
+    $displaywith = ['otherserial', 'serial'];
+    $p = [
         'value'               => 0,
         'valuename'           => Dropdown::EMPTY_VALUE,
         'itemtype'            => $_POST["idtable"],
         'display_emptychoice' => true,
-        'displaywith'         => ['otherserial', 'serial'],
-        '_idor_token'         => Session::getNewIDORToken($_POST["idtable"]),
+        'displaywith'         => $displaywith,
+    ];
+    $idor_params = [
+        'displaywith' => $displaywith,
     ];
     if (isset($_POST['value'])) {
         $p['value'] = $_POST['value'];
     }
     if (isset($_POST['entity_restrict'])) {
-        $p['entity_restrict'] = $_POST['entity_restrict'];
+        $p['entity_restrict']           = $_POST['entity_restrict'];
+        $idor_params['entity_restrict'] = $_POST['entity_restrict'];
     }
     if (isset($_POST['condition'])) {
-        $p['condition'] = $_POST['condition'];
+        $p['condition']           = $_POST['condition'];
+        $idor_params['condition'] = $_POST['condition'];
     }
     if (isset($_POST['used'])) {
         $_POST['used'] = Toolbox::jsonDecode($_POST['used'], true);
     }
     if (isset($_POST['used'][$_POST['idtable']])) {
         $p['used'] = $_POST['used'][$_POST['idtable']];
     }
     if (isset($_POST['width'])) {
         $p['width'] = $_POST['width'];
     }
+    $p['_idor_token'] = Session::getNewIDORToken($_POST["idtable"], $idor_params);
 
     echo  Html::jsAjaxDropdown(
         $_POST["name"],
         $field_id,
         $CFG_GLPI['root_doc'] . "/ajax/" . $link,
diff --git a/ajax/socket.php b/ajax/socket.php
index 1f99dbec3fa1..122e60d5f81a 100644
--- a/ajax/socket.php
+++ b/ajax/socket.php
@@ -37,11 +37,11 @@ include('../inc/includes.php');
 
 // Send UTF8 Headers
 header("Content-Type: text/html; charset=UTF-8");
 Html::header_nocache();
 
-Session::checkLoginUser();
+Session::checkCentralAccess();
 
 switch ($_POST['action']) {
     case 'getItemsFromItemtype':
         if ($_POST['itemtype'] && class_exists($_POST['itemtype'])) {
             $_POST['itemtype']::dropdown(['name'                => $_POST['dom_name'],
diff --git a/ajax/visibility.php b/ajax/visibility.php
index 78f1d9bd4e55..08f083a5e981 100644
--- a/ajax/visibility.php
+++ b/ajax/visibility.php
@@ -42,11 +42,11 @@ if (strpos($_SERVER['PHP_SELF'], "visibility.php")) {
     include('../inc/includes.php');
     header("Content-Type: text/html; charset=UTF-8");
     Html::header_nocache();
 }
 
-Session::checkLoginUser();
+Session::checkCentralAccess();
 
 if (
     isset($_POST['type']) && !empty($_POST['type'])
     && isset($_POST['right'])
 ) {
diff --git a/src/Dropdown.php b/src/Dropdown.php
index 3c0535c4b87a..c5d48bbb7f8a 100644
--- a/src/Dropdown.php
+++ b/src/Dropdown.php
@@ -231,13 +231,18 @@ class Dropdown
             'entity_restrict'      => ($entity_restrict = (is_array($params['entity']) ? json_encode(array_values($params['entity'])) : $params['entity'])),
             'on_change'            => $params['on_change'],
             'permit_select_parent' => $params['permit_select_parent'],
             'specific_tags'        => $params['specific_tags'],
             'class'                => $params['class'],
-            '_idor_token'          => Session::getNewIDORToken($itemtype, [
-                'entity_restrict' => $entity_restrict,
-            ]),
+            '_idor_token'          => Session::getNewIDORToken(
+                $itemtype,
+                [
+                    'entity_restrict' => $entity_restrict,
+                    'displaywith'     => $params['displaywith'],
+                    'condition'       => $params['condition'],
+                ],
+            ),
             'order'                => $params['order'] ?? null,
             'parent_id_field'      => $params['parent_id_field'],
             'multiple'             => $params['multiple'] ?? false,
         ];
 
@@ -2648,16 +2653,12 @@ JAVASCRIPT;
         $datas = [];
 
         $displaywith = false;
         if (isset($post['displaywith'])) {
             if (is_array($post['displaywith']) && count($post['displaywith'])) {
-                $table = getTableForItemType($post['itemtype']);
-                foreach ($post['displaywith'] as $key => $value) {
-                    if (!$DB->fieldExists($table, $value)) {
-                        unset($post['displaywith'][$key]);
-                    }
-                }
+                $post['displaywith'] = self::filterDisplayWith($item, $post['displaywith']);
+
                 if (count($post['displaywith'])) {
                     $displaywith = true;
                 }
             }
         }
@@ -3465,10 +3466,32 @@ JAVASCRIPT;
         $ret['count']   = $count;
 
         return ($json === true) ? json_encode($ret) : $ret;
     }
 
+    private static function filterDisplayWith(CommonDBTM $item, array $fields): array
+    {
+        /** @var \DBmysql $DB */
+        global $DB;
+
+        // Filter invalid fields
+        $table = $item->getTable();
+        foreach ($fields as $key => $value) {
+            if (!$DB->fieldExists($table, $value)) {
+                unset($fields[$key]);
+            }
+        }
+
+        // Filter sensitive fields in `displaywith`
+        // `CommonDBTM::unsetUndisclosedFields()` expects a `$field => $value` format.
+        $key_value_fields = array_fill_keys($fields, 0);
+        $item::unsetUndisclosedFields($key_value_fields);
+        $fields = array_keys($key_value_fields);
+
+        return $fields;
+    }
+
     /**
      * Get dropdown connect
      *
      * @param array   $post Posted values
      * @param boolean $json Encode to JSON, default to true
@@ -4093,11 +4116,10 @@ JAVASCRIPT;
             'users_right'        => 'all',
             'used'               => [],
             'value'              => 0,
             'page'               => 1,
             'inactive_deleted'   => 0,
-            '_idor_token'        => "",
             'searchText'         => null,
             'itiltemplate_class' => 'TicketTemplate',
             'itiltemplates_id'   => 0,
             'returned_itemtypes' => ['User', 'Group', 'Supplier'],
             'page_limit'         => $CFG_GLPI['dropdown_max'],
@@ -4160,13 +4182,16 @@ JAVASCRIPT;
             if ($post["actortype"] == 'observer') {
                 $cond = ['is_watcher' => 1];
             }
             $post['condition'] = static::addNewCondition($cond);
 
+            // Bypass checks, idor token validation has already been made earlier in method
+            $group_idor = Session::getNewIDORToken('Group', ['entity_restrict' => $entity_restrict, 'condition' => $post['condition']]);
+
             $groups = Dropdown::getDropdownValue([
                 'itemtype'            => 'Group',
-                '_idor_token'         => $post['_idor_token'],
+                '_idor_token'         => $group_idor,
                 'display_emptychoice' => false,
                 'searchText'          => $post['searchText'],
                 'entity_restrict'     => $entity_restrict,
                 'condition'           => $post['condition'],
             ], false);
@@ -4189,21 +4214,24 @@ JAVASCRIPT;
         if (
             $post["actortype"] == 'assign'
             && !$template->isHiddenField("_suppliers_id_{$post['actortype']}")
             && in_array('Supplier', $post['returned_itemtypes'])
         ) {
-            $supplier_obj = new Supplier();
+            // Bypass checks, idor token validation has already been made earlier in method
+            $supplier_idor = Session::getNewIDORToken('Group', ['entity_restrict' => $entity_restrict]);
+
             $suppliers    = Dropdown::getDropdownValue([
                 'itemtype'            => 'Supplier',
-                '_idor_token'         => $post['_idor_token'],
+                '_idor_token'         => $supplier_idor,
                 'display_emptychoice' => false,
                 'searchText'          => $post['searchText'],
                 'entity_restrict'     => $entity_restrict,
             ], false);
             foreach ($suppliers['results'] as $supplier) {
                 if (isset($supplier['children'])) {
                     foreach ($supplier['children'] as &$children) {
+                        $supplier_obj = new Supplier();
                         $supplier_obj->getFromDB($children['id']);
 
                         $children['items_id']          = $children['id'];
                         $children['id']                = "Supplier_" . $children['id'];
                         $children['itemtype']          = "Supplier";
diff --git a/src/Session.php b/src/Session.php
index 64d317e6d2e3..324d7971f1bb 100644
--- a/src/Session.php
+++ b/src/Session.php
@@ -1635,21 +1635,26 @@ class Session
 
     /**
      * Get new IDOR token
      * This token validates the itemtype used by an ajax request is the one asked by a dropdown.
      * So, we avoid IDOR request where an attacker asks for an another itemtype
-     * than the originaly indtended
+     * than the originaly intended
      *
      * @since 9.5.3
      *
      * @param string $itemtype
      * @param array  $add_params more criteria to check validy of idor tokens
      *
      * @return string
      **/
     public static function getNewIDORToken(string $itemtype = "", array $add_params = []): string
     {
+        if ($itemtype === '' && count($add_params) === 0) {
+            trigger_error('IDOR token cannot be generated with empty criteria.', E_USER_WARNING);
+            return '';
+        }
+
         $token = "";
         do {
             $token = bin2hex(random_bytes(32));
         } while ($token == '');
 
@@ -1693,11 +1698,25 @@ class Session
             && $_SESSION['glpiidortokens'][$token]['expires'] >= time()
         ) {
             $idor_data =  $_SESSION['glpiidortokens'][$token];
             unset($idor_data['expires']);
 
-           // check all stored data for the idor token are present (and identifical) in the posted data
+            // Ensure that `displaywith` and `condition` is checked if passed in data
+            $mandatory_properties = [
+                'displaywith' => [],
+                'condition'   => [],
+            ];
+            foreach ($mandatory_properties as $property_name => $default_value) {
+                if (!array_key_exists($property_name, $data)) {
+                    $data[$property_name] = $default_value;
+                }
+                if (!array_key_exists($property_name, $idor_data)) {
+                    $idor_data[$property_name] = $default_value;
+                }
+            }
+
+           // check all stored data for the idor token are present (and identical) in the posted data
             $match_expected = function ($expected, $given) use (&$match_expected) {
                 if (is_array($expected)) {
                     if (!is_array($given)) {
                         return false;
                     }
diff --git a/templates/components/itilobject/actors/field.html.twig b/templates/components/itilobject/actors/field.html.twig
index 347955576855..21031b232703 100644
--- a/templates/components/itilobject/actors/field.html.twig
+++ b/templates/components/itilobject/actors/field.html.twig
@@ -252,11 +252,11 @@
                   action: 'getActors',
                   actortype: actorytype,
                   users_right: '{{ users_right ?? 'all' }}',
                   entity_restrict: (actors.requester.length == 0 && is_new_item) ? -1 : {{ entities_id }},
                   searchText: params.term,
-                  _idor_token: '{{ idor_token() }}',
+                  _idor_token: '{{ idor_token(item.getType(), {'users_right': users_right ?? 'all'}) }}',
                   itiltemplate_class: '{{ itiltemplate.getType() }}',
                   itiltemplates_id: {{ itiltemplate.fields['id'] ?? 0 }},
                   itemtype: '{{ item.getType() }}',
                   items_id: {{ item.isNewItem() ? -1 : item.fields['id'] }},
                   item: {{ item.fields|json_encode|raw }},
diff --git a/tests/functional/Dropdown.php b/tests/functional/Dropdown.php
index 487c1f6e4d5a..b96b219dfb53 100644
--- a/tests/functional/Dropdown.php
+++ b/tests/functional/Dropdown.php
@@ -33,16 +33,20 @@
  * ---------------------------------------------------------------------
  */
 
 namespace tests\units;
 
+use CommonDBTM;
+use Computer;
 use DbTestCase;
 use Generator;
 use Glpi\Socket;
 use Glpi\Toolbox\Sanitizer;
+use Item_DeviceSimcard;
 use Session;
 use State;
+use User;
 
 /* Test for inc/dropdown.class.php */
 
 class Dropdown extends DbTestCase
 {
@@ -1410,11 +1414,11 @@ class Dropdown extends DbTestCase
             'itemtype'              => $location::getType(),
             'display_emptychoice'   => true,
             'entity_restrict'       => 0,
             'page'                  => 1,
             'page_limit'            => 10,
-            '_idor_token'           => \Session::getNewIDORToken($location::getType())
+            '_idor_token'           => \Session::getNewIDORToken($location::getType(), ['entity_restrict' => 0])
         ];
         $values = \Dropdown::getDropdownValue($post);
         $values = (array)json_decode($values);
 
         $this->array($values)
@@ -1469,11 +1473,11 @@ class Dropdown extends DbTestCase
             'condition'             => ['name' => ['LIKE', "%3%"]],
             'display_emptychoice'   => true,
             'entity_restrict'       => 0,
             'page'                  => 1,
             'page_limit'            => 10,
-            '_idor_token'           => \Session::getNewIDORToken($location::getType())
+            '_idor_token'           => \Session::getNewIDORToken($location::getType(), ['entity_restrict' => 0, 'condition' => ['name' => ['LIKE', "%3%"]]])
         ];
         $values = \Dropdown::getDropdownValue($post);
         $values = (array)json_decode($values);
 
         $this->array($values)
@@ -1483,11 +1487,12 @@ class Dropdown extends DbTestCase
 
        //use a string condition
        // Put condition in session and post its key
         $condition_key = sha1(serialize($post['condition']));
         $_SESSION['glpicondition'][$condition_key] = $post['condition'];
-        $post['condition'] = $condition_key;
+        $post['condition']   = $condition_key;
+        $post['_idor_token'] = \Session::getNewIDORToken($location::getType(), ['entity_restrict' => 0, 'condition' => $condition_key]);
         $values = \Dropdown::getDropdownValue($post);
         $values = (array)json_decode($values);
 
         $this->array($values)
          ->integer['count']->isEqualTo(2)
@@ -1500,11 +1505,11 @@ class Dropdown extends DbTestCase
             'condition'             => '`name` LIKE "%4%"',
             'display_emptychoice'   => true,
             'entity_restrict'       => 0,
             'page'                  => 1,
             'page_limit'            => 10,
-            '_idor_token'           => \Session::getNewIDORToken($location::getType())
+            '_idor_token'           => \Session::getNewIDORToken($location::getType(), ['entity_restrict' => 0, 'condition' => '`name` LIKE "%4%"'])
         ];
         $values = \Dropdown::getDropdownValue($post);
         $values = (array)json_decode($values);
 
         $this->array($values)
@@ -1817,6 +1822,72 @@ class Dropdown extends DbTestCase
             $this->variable($dropdown_entry['id'])->isEqualTo($numeric_text_value);
 
             $this->variable($dropdown_entry['id'])->isEqualTo($expected[$key]);
         }
     }
+
+    protected function displayWithProvider(): iterable
+    {
+        yield [
+            'item'        => new Computer(),
+            'displaywith' => [],
+            'filtered'    => [],
+        ];
+
+        yield [
+            'item'        => new Computer(),
+            'displaywith' => ['id', 'notavalidfield', 'serial'],
+            'filtered'    => ['id', 'serial'],
+        ];
+
+        $this->login('post-only', 'postonly');
+        yield [
+            'item'        => new Item_DeviceSimcard(),
+            'displaywith' => ['serial', 'pin', 'puk'],
+            'filtered'    => ['serial'], // pin and puk disallowed by profile
+        ];
+
+        $this->login();
+        yield [
+            'item'        => new Item_DeviceSimcard(),
+            'displaywith' => ['serial', 'pin', 'puk'],
+            'filtered'    => ['serial', 'pin', 'puk'], // pin and puk allowed by profile
+        ];
+
+        $this->logOut();
+        yield [
+            'item'        => new Item_DeviceSimcard(),
+            'displaywith' => ['serial', 'pin', 'puk'],
+            'filtered'    => ['serial'], // pin and puk disallowed when not connected
+        ];
+
+        $this->login('post-only', 'postonly');
+        yield [
+            'item'        => new User(),
+            'displaywith' => ['id', 'firstname', 'password', 'personal_token', 'api_token', 'cookie_token', 'password_forget_token'],
+            'filtered'    => ['id', 'firstname'], // all sensitive fields removed, and password_forget_token disallowed by profile
+        ];
+
+        $this->login();
+        yield [
+            'item'        => new User(),
+            'displaywith' => ['id', 'firstname', 'password', 'personal_token', 'api_token', 'cookie_token', 'password_forget_token'],
+            'filtered'    => ['id', 'firstname', 'password_forget_token'], // password_forget_token allowed by profile
+        ];
+
+        $this->logOut();
+        yield [
+            'item'        => new User(),
+            'displaywith' => ['id', 'firstname', 'password', 'personal_token', 'api_token', 'cookie_token', 'password_forget_token'],
+            'filtered'    => ['id', 'firstname'], // all sensitive fields removed, and password_forget_token disallowed when not connected
+        ];
+    }
+
+    /**
+     * @dataProvider displayWithProvider
+     */
+    public function testFilterDisplayWith(CommonDBTM $item, array $displaywith, array $filtered): void
+    {
+        $instance = $this->newTestedInstance();
+        $this->array($this->callPrivateMethod($instance, 'filterDisplayWith', $item, $displaywith))->isEqualTo($filtered);
+    }
 }
diff --git a/tests/functional/Session.php b/tests/functional/Session.php
index cf63da362b63..1f943edb72ab 100644
--- a/tests/functional/Session.php
+++ b/tests/functional/Session.php
@@ -378,48 +378,204 @@ class Session extends \DbTestCase
         $CFG_GLPI = $cfg_backup;
 
         $this->string($result)->isEqualTo($expected);
     }
 
-
-    protected function idorProvider()
+    protected function newIdorParamsProvider()
     {
-        return [
-            ['itemtype' => 'Computer'],
-            ['itemtype' => 'Ticket'],
-            ['itemtype' => 'Glpi\\Dashboard\\Item'],
-            ['itemtype' => 'User', 'add_params' => ['right' => 'all']],
-            ['itemtype' => 'User', 'add_params' => ['entity_restrict' => 0]],
+        // No extra params
+        foreach (['Computer', 'Ticket', 'Glpi\\Dashboard\\Item'] as $itemtype) {
+            yield [
+                'itemtype' => $itemtype,
+            ];
+        }
+
+        // No itemtype
+        yield [
+            'add_params' => ['entity_restrict' => [0, 1, 2, 3, 5, 9, 1578]]
+        ];
+
+        // With itemtype and extra params
+        yield [
+            'itemtype'   => 'User',
+            'add_params' => ['right' => 'all'],
+        ];
+        yield [
+            'itemtype'   => 'User',
+            'add_params' => ['entity_restrict' => 0],
         ];
     }
 
     /**
-     * @dataProvider idorProvider
+     * @dataProvider newIdorParamsProvider
      */
-    public function testIDORToken(string $itemtype = "", array $add_params = [])
+    public function testGetNewIDORToken(string $itemtype = "", array $add_params = [])
     {
-       // generate token
+        $initial_time = time();
+
+        // generate token
         $token = \Session::getNewIDORToken($itemtype, $add_params);
         $this->string($token)->hasLength(64);
 
-       // token exists in session and is valid
-        $this->array($_SESSION['glpiidortokens'][$token])
-         ->string['itemtype']->isEqualTo($itemtype)
-         ->string['expires'];
-
-        if (count($add_params) > 0) {
-            $this->array($_SESSION['glpiidortokens'][$token])->size->isEqualTo(2 + count($add_params));
+        // token exists in session and is valid
+        $this->array($token_data = $_SESSION['glpiidortokens'][$token]);
+        if ($itemtype !== '') {
+            $this->array($token_data)->size->isEqualTo(2 + count($add_params));
+            $this->array($token_data)->string['itemtype']->isEqualTo($itemtype);
+        } else {
+            $this->array($token_data)->size->isEqualTo(1 + count($add_params));
         }
+        $this->array($token_data)->integer['expires']->isGreaterThanOrEqualTo($initial_time + GLPI_IDOR_EXPIRES);
 
-       // validate token with dedicated method
-        $result = \Session::validateIDOR([
+        // validate token
+        $data = [
             '_idor_token' => $token,
             'itemtype'    => $itemtype,
-        ] + $add_params);
-        $this->boolean($result)->isTrue();
+        ] + $add_params;
+        $this->boolean(\Session::validateIDOR($data))->isTrue();
+    }
+
+    protected function idorDataProvider()
+    {
+        yield [
+            'data'     => [],
+            'is_valid' => false, // no token provided
+        ];
+
+        $token = \Session::getNewIDORToken(
+            'User',
+            [
+                'test'    => 1,
+                'complex' => ['foo', 'bar', [1, 2]],
+            ]
+        );
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'test'        => 1,
+                'complex'     => ['foo', 'bar', [1, 2]],
+            ],
+            'is_valid' => true,
+        ];
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'test'        => 1,
+                'complex'     => ['foo', 'bar', [1, 2]],
+                'displaywith' => [], // empty displaywith is OK
+            ],
+            'is_valid' => true,
+        ];
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'complex'     => ['foo', 'bar', [1, 2]],
+            ],
+            'is_valid' => false, // missing `test`
+        ];
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'test'        => 1,
+            ],
+            'is_valid' => false, // missing `complex`
+        ];
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'test'        => 1,
+                'complex'     => 'foo,bar,1,2',
+            ],
+            'is_valid' => false, // invalid `complex`
+        ];
+
+        $token = \Session::getNewIDORToken(
+            'User',
+            [
+                'displaywith' => ['id', 'phone'],
+            ]
+        );
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'displaywith' => ['id', 'phone'],
+            ],
+            'is_valid' => true,
+        ];
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'displaywith' => ['phone'],
+            ],
+            'is_valid' => false, // id missing in displaywith
+        ];
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+            ],
+            'is_valid' => false, // missing displaywith
+        ];
+
+        $condition_sha = \Dropdown::addNewCondition(['a' => 5, 'b' => true]);
+        $token = \Session::getNewIDORToken(
+            'User',
+            [
+                'condition' => $condition_sha,
+            ]
+        );
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'condition'   => $condition_sha,
+            ],
+            'is_valid' => true,
+        ];
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+                'condition'   => \Dropdown::addNewCondition(['a' => 1, 'b' => true]),
+            ],
+            'is_valid' => false, // not the same condition
+        ];
+        yield [
+            'data'     => [
+                'itemtype'    => 'User',
+                '_idor_token' => $token,
+            ],
+            'is_valid' => false, // missing condition
+        ];
+    }
+
+    /**
+     * @dataProvider idorDataProvider
+     */
+    public function testValidateIDOR(array $data, bool $is_valid)
+    {
+        $this->boolean(\Session::validateIDOR($data))->isEqualTo($is_valid);
     }
 
+    public function testGetNewIDORTokenWithEmptyParams()
+    {
+        $this->when(
+            function () {
+                \Session::getNewIDORToken();
+            }
+        )->error
+         ->withType(E_USER_WARNING)
+         ->withMessage('IDOR token cannot be generated with empty criteria.')
+         ->exists();
+    }
 
     public function testDORInvalid()
     {
        //  random token
         $result = \Session::validateIDOR([

Vulnerable code: Session::checkLoginUser();
    $p        = [
        'displaywith'         => ['otherserial', 'serial'],
        '_idor_token'         => Session::getNewIDORToken($_POST["idtable"]),
        $p['entity_restrict'] = $_POST['entity_restrict'];
        $p['condition'] = $_POST['condition'];
Session::checkLoginUser();
Session::checkLoginUser();
            '_idor_token'          => Session::getNewIDORToken($itemtype, [
                'entity_restrict' => $entity_restrict,
            ]),
                $table = getTableForItemType($post['itemtype']);
                foreach ($post['displaywith'] as $key => $value) {
                    if (!$DB->fieldExists($table, $value)) {
                        unset($post['displaywith'][$key]);
                    }
                }
            '_idor_token'        => "",
                '_idor_token'         => $post['_idor_token'],
            $supplier_obj = new Supplier();
                '_idor_token'         => $post['_idor_token'],
     * than the originaly indtended
           // check all stored data for the idor token are present (and identifical) in the posted data
                  _idor_token: '{{ idor_token() }}',
            '_idor_token'           => \Session::getNewIDORToken($location::getType())
            '_idor_token'           => \Session::getNewIDORToken($location::getType())
        $post['condition'] = $condition_key;
            '_idor_token'           => \Session::getNewIDORToken($location::getType())

    protected function idorProvider()
        return [
            ['itemtype' => 'Computer'],
            ['itemtype' => 'Ticket'],
            ['itemtype' => 'Glpi\\Dashboard\\Item'],
            ['itemtype' => 'User', 'add_params' => ['right' => 'all']],
            ['itemtype' => 'User', 'add_params' => ['entity_restrict' => 0]],
     * @dataProvider idorProvider
    public function testIDORToken(string $itemtype = "", array $add_params = [])
       // generate token
       // token exists in session and is valid
        $this->array($_SESSION['glpiidortokens'][$token])
         ->string['itemtype']->isEqualTo($itemtype)
         ->string['expires'];

        if (count($add_params) > 0) {
            $this->array($_SESSION['glpiidortokens'][$token])->size->isEqualTo(2 + count($add_params));
       // validate token with dedicated method
        $result = \Session::validateIDOR([
        ] + $add_params);
        $this->boolean($result)->isTrue();
Fixed code: - `condition` and `displaywith` parameters must now be added in IDOR token creation data when they are not empty.
Session::checkCentralAccess();
    $displaywith = ['otherserial', 'serial'];
    $p = [
        'displaywith'         => $displaywith,
    ];
    $idor_params = [
        'displaywith' => $displaywith,
        $p['entity_restrict']           = $_POST['entity_restrict'];
        $idor_params['entity_restrict'] = $_POST['entity_restrict'];
        $p['condition']           = $_POST['condition'];
        $idor_params['condition'] = $_POST['condition'];
    $p['_idor_token'] = Session::getNewIDORToken($_POST["idtable"], $idor_params);
Session::checkCentralAccess();
Session::checkCentralAccess();
            '_idor_token'          => Session::getNewIDORToken(
                $itemtype,
                [
                    'entity_restrict' => $entity_restrict,
                    'displaywith'     => $params['displaywith'],
                    'condition'       => $params['condition'],
                ],
            ),
                $post['displaywith'] = self::filterDisplayWith($item, $post['displaywith']);

    private static function filterDisplayWith(CommonDBTM $item, array $fields): array
    {
        /** @var \DBmysql $DB */
        global $DB;

        // Filter invalid fields
        $table = $item->getTable();
        foreach ($fields as $key => $value) {
            if (!$DB->fieldExists($table, $value)) {
                unset($fields[$key]);
            }
        }

        // Filter sensitive fields in `displaywith`
        // `CommonDBTM::unsetUndisclosedFields()` expects a `$field => $value` format.
        $key_value_fields = array_fill_keys($fields, 0);
        $item::unsetUndisclosedFields($key_value_fields);
        $fields = array_keys($key_value_fields);

        return $fields;
    }

            // Bypass checks, idor token validation has already been made earlier in method
            $group_idor = Session::getNewIDORToken('Group', ['entity_restrict' => $entity_restrict, 'condition' => $post['condition']]);

                '_idor_token'         => $group_idor,
            // Bypass checks, idor token validation has already been made earlier in method
            $supplier_idor = Session::getNewIDORToken('Group', ['entity_restrict' => $entity_restrict]);

                '_idor_token'         => $supplier_idor,
                        $supplier_obj = new Supplier();
     * than the originaly intended
        if ($itemtype === '' && count($add_params) === 0) {
            trigger_error('IDOR token cannot be generated with empty criteria.', E_USER_WARNING);
            return '';
        }

            // Ensure that `displaywith` and `condition` is checked if passed in data
            $mandatory_properties = [
                'displaywith' => [],
                'condition'   => [],
            ];
            foreach ($mandatory_properties as $property_name => $default_value) {
                if (!array_key_exists($property_name, $data)) {
                    $data[$property_name] = $default_value;
                }
                if (!array_key_exists($property_name, $idor_data)) {
                    $idor_data[$property_name] = $default_value;
                }
            }

           // check all stored data for the idor token are present (and identical) in the posted data
                  _idor_token: '{{ idor_token(item.getType(), {'users_right': users_right ?? 'all'}) }}',
use CommonDBTM;
use Computer;
use Item_DeviceSimcard;
use User;
            '_idor_token'           => \Session::getNewIDORToken($location::getType(), ['entity_restrict' => 0])
            '_idor_token'           => \Session::getNewIDORToken($location::getType(), ['entity_restrict' => 0, 'condition' => ['name' => ['LIKE', "%3%"]]])
        $post['condition']   = $condition_key;
        $post['_idor_token'] = \Session::getNewIDORToken($location::getType(), ['entity_restrict' => 0, 'condition' => $condition_key]);
            '_idor_token'           => \Session::getNewIDORToken($location::getType(), ['entity_restrict' => 0, 'condition' => '`name` LIKE "%4%"'])

    protected function displayWithProvider(): iterable
    {
        yield [
            'item'        => new Computer(),
            'displaywith' => [],
            'filtered'    => [],
        ];

        yield [
            'item'        => new Computer(),
            'displaywith' => ['id', 'notavalidfield', 'serial'],
            'filtered'    => ['id', 'serial'],
        ];

        $this->login('post-only', 'postonly');
        yield [
            'item'        => new Item_DeviceSimcard(),
            'displaywith' => ['serial', 'pin', 'puk'],
            'filtered'    => ['serial'], // pin and puk disallowed by profile
        ];

        $this->login();
        yield [
            'item'        => new Item_DeviceSimcard(),
            'displaywith' => ['serial', 'pin', 'puk'],
            'filtered'    => ['serial', 'pin', 'puk'], // pin and puk allowed by profile
        ];

        $this->logOut();
        yield [
            'item'        => new Item_DeviceSimcard(),
            'displaywith' => ['serial', 'pin', 'puk'],
            'filtered'    => ['serial'], // pin and puk disallowed when not connected
        ];

        $this->login('post-only', 'postonly');
        yield [
            'item'        => new User(),
            'displaywith' => ['id', 'firstname', 'password', 'personal_token', 'api_token', 'cookie_token', 'password_forget_token'],
            'filtered'    => ['id', 'firstname'], // all sensitive fields removed, and password_forget_token disallowed by profile
        ];

        $this->login();
        yield [
            'item'        => new User(),
            'displaywith' => ['id', 'firstname', 'password', 'personal_token', 'api_token', 'cookie_token', 'password_forget_token'],
            'filtered'    => ['id', 'firstname', 'password_forget_token'], // password_forget_token allowed by profile
        ];

        $this->logOut();
        yield [
            'item'        => new User(),
            'displaywith' => ['id', 'firstname', 'password', 'personal_token', 'api_token', 'cookie_token', 'password_forget_token'],
            'filtered'    => ['id', 'firstname'], // all sensitive fields removed, and password_forget_token disallowed when not connected
        ];
    }

    /**
     * @dataProvider displayWithProvider
     */
    public function testFilterDisplayWith(CommonDBTM $item, array $displaywith, array $filtered): void
    {
        $instance = $this->newTestedInstance();
        $this->array($this->callPrivateMethod($instance, 'filterDisplayWith', $item, $displaywith))->isEqualTo($filtered);
    }
    protected function newIdorParamsProvider()
        // No extra params
        foreach (['Computer', 'Ticket', 'Glpi\\Dashboard\\Item'] as $itemtype) {
            yield [
                'itemtype' => $itemtype,
            ];
        }

        // No itemtype
        yield [
            'add_params' => ['entity_restrict' => [0, 1, 2, 3, 5, 9, 1578]]
        ];

        // With itemtype and extra params
        yield [
            'itemtype'   => 'User',
            'add_params' => ['right' => 'all'],
        ];
        yield [
            'itemtype'   => 'User',
            'add_params' => ['entity_restrict' => 0],
     * @dataProvider newIdorParamsProvider
    public function testGetNewIDORToken(string $itemtype = "", array $add_params = [])
        $initial_time = time();

        // generate token
        // token exists in session and is valid
        $this->array($token_data = $_SESSION['glpiidortokens'][$token]);
        if ($itemtype !== '') {
            $this->array($token_data)->size->isEqualTo(2 + count($add_params));
            $this->array($token_data)->string['itemtype']->isEqualTo($itemtype);
        } else {
            $this->array($token_data)->size->isEqualTo(1 + count($add_params));
        $this->array($token_data)->integer['expires']->isGreaterThanOrEqualTo($initial_time + GLPI_IDOR_EXPIRES);
        // validate token
        $data = [
        ] + $add_params;
        $this->boolean(\Session::validateIDOR($data))->isTrue();
    }

    protected function idorDataProvider()
    {
        yield [
            'data'     => [],
            'is_valid' => false, // no token provided
        ];

        $token = \Session::getNewIDORToken(
            'User',
            [
                'test'    => 1,
                'complex' => ['foo', 'bar', [1, 2]],
            ]
        );
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'test'        => 1,
                'complex'     => ['foo', 'bar', [1, 2]],
            ],
            'is_valid' => true,
        ];
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'test'        => 1,
                'complex'     => ['foo', 'bar', [1, 2]],
                'displaywith' => [], // empty displaywith is OK
            ],
            'is_valid' => true,
        ];
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'complex'     => ['foo', 'bar', [1, 2]],
            ],
            'is_valid' => false, // missing `test`
        ];
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'test'        => 1,
            ],
            'is_valid' => false, // missing `complex`
        ];
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'test'        => 1,
                'complex'     => 'foo,bar,1,2',
            ],
            'is_valid' => false, // invalid `complex`
        ];

        $token = \Session::getNewIDORToken(
            'User',
            [
                'displaywith' => ['id', 'phone'],
            ]
        );
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'displaywith' => ['id', 'phone'],
            ],
            'is_valid' => true,
        ];
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'displaywith' => ['phone'],
            ],
            'is_valid' => false, // id missing in displaywith
        ];
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
            ],
            'is_valid' => false, // missing displaywith
        ];

        $condition_sha = \Dropdown::addNewCondition(['a' => 5, 'b' => true]);
        $token = \Session::getNewIDORToken(
            'User',
            [
                'condition' => $condition_sha,
            ]
        );
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'condition'   => $condition_sha,
            ],
            'is_valid' => true,
        ];
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
                'condition'   => \Dropdown::addNewCondition(['a' => 1, 'b' => true]),
            ],
            'is_valid' => false, // not the same condition
        ];
        yield [
            'data'     => [
                'itemtype'    => 'User',
                '_idor_token' => $token,
            ],
            'is_valid' => false, // missing condition
        ];
    }

    /**
     * @dataProvider idorDataProvider
     */
    public function testValidateIDOR(array $data, bool $is_valid)
    {
        $this->boolean(\Session::validateIDOR($data))->isEqualTo($is_valid);
    public function testGetNewIDORTokenWithEmptyParams()
    {
        $this->when(
            function () {
                \Session::getNewIDORToken();
            }
        )->error
         ->withType(E_USER_WARNING)
         ->withMessage('IDOR token cannot be generated with empty criteria.')
         ->exists();
    }