CVE-2024-27930 (published 2024-03-18T16:15Z)

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.


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/glpi-project/glpi
commit_details: repo_url: https://github.com/glpi-project/glpi
author: Cédric Anne
author_date: 2024-03-13 08:16:43+01:00
msg: 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
repository: repo_url: https://github.com/glpi-project/glpi
repo_name: glpi-project/glpi
description: GLPI is a Free Asset and IT Management Software package, Data center management, ITIL Service Desk, licenses tracking and software auditing.
repo_language: PHP
owner: glpi-project
file_changes: filename: CHANGELOG.md
change_type: ModificationType.MODIFY
diff: @@ -18,6 +18,7 @@ The present file will list all changes made to the project; according to the
 #### Added
 
 #### Changes
+- `condition` and `displaywith` parameters must now be added in IDOR token creation data when they are not empty.
 
 #### Deprecated
 

programming_language: Markdown
filename: cable.php
change_type: ModificationType.MODIFY
diff: @@ -43,7 +43,7 @@ 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"];
 

programming_language: PHP
filename: dropdownAllItems.php
change_type: ModificationType.MODIFY
diff: @@ -56,22 +56,27 @@ if ($_POST["idtable"] && class_exists($_POST["idtable"])) {
 
     $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);
@@ -82,6 +87,7 @@ if ($_POST["idtable"] && class_exists($_POST["idtable"])) {
     if (isset($_POST['width'])) {
         $p['width'] = $_POST['width'];
     }
+    $p['_idor_token'] = Session::getNewIDORToken($_POST["idtable"], $idor_params);
 
     echo  Html::jsAjaxDropdown(
         $_POST["name"],

programming_language: PHP
filename: socket.php
change_type: ModificationType.MODIFY
diff: @@ -39,7 +39,7 @@ include('../inc/includes.php');
 header("Content-Type: text/html; charset=UTF-8");
 Html::header_nocache();
 
-Session::checkLoginUser();
+Session::checkCentralAccess();
 
 switch ($_POST['action']) {
     case 'getItemsFromItemtype':

programming_language: PHP
filename: visibility.php
change_type: ModificationType.MODIFY
diff: @@ -44,7 +44,7 @@ if (strpos($_SERVER['PHP_SELF'], "visibility.php")) {
     Html::header_nocache();
 }
 
-Session::checkLoginUser();
+Session::checkCentralAccess();
 
 if (
     isset($_POST['type']) && !empty($_POST['type'])

programming_language: PHP
filename: Dropdown.php
change_type: ModificationType.MODIFY
diff: @@ -233,9 +233,14 @@ class Dropdown
             '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,
@@ -2650,12 +2655,8 @@ JAVASCRIPT;
         $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;
                 }
@@ -3467,6 +3468,28 @@ JAVASCRIPT;
         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
      *
@@ -4095,7 +4118,6 @@ JAVASCRIPT;
             'value'              => 0,
             'page'               => 1,
             'inactive_deleted'   => 0,
-            '_idor_token'        => "",
             'searchText'         => null,
             'itiltemplate_class' => 'TicketTemplate',
             'itiltemplates_id'   => 0,
@@ -4162,9 +4184,12 @@ JAVASCRIPT;
             }
             $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,
@@ -4191,10 +4216,12 @@ JAVASCRIPT;
             && !$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,
@@ -4202,6 +4229,7 @@ JAVASCRIPT;
             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'];

programming_language: PHP
filename: Session.php
change_type: ModificationType.MODIFY
diff: @@ -1637,7 +1637,7 @@ 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
      *
@@ -1648,6 +1648,11 @@ class Session
      **/
     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));
@@ -1695,7 +1700,21 @@ class Session
             $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)) {

programming_language: PHP
filename: field.html.twig
change_type: ModificationType.MODIFY
diff: @@ -254,7 +254,7 @@
                   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() }}',

programming_language: HTML
filename: Dropdown.php
change_type: ModificationType.MODIFY
diff: @@ -35,12 +35,16 @@
 
 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 */
 
@@ -1412,7 +1416,7 @@ class Dropdown extends DbTestCase
             '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);
@@ -1471,7 +1475,7 @@ class Dropdown extends DbTestCase
             '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);
@@ -1485,7 +1489,8 @@ class Dropdown extends DbTestCase
        // 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);
 
@@ -1502,7 +1507,7 @@ class Dropdown extends DbTestCase
             '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);
@@ -1819,4 +1824,70 @@ class Dropdown extends DbTestCase
             $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);
+    }
 }

programming_language: PHP
filename: Session.php
change_type: ModificationType.MODIFY
diff: @@ -380,44 +380,200 @@ class Session extends \DbTestCase
         $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()
     {

programming_language: PHP