CVE-2024-27930 (PHP, published 2024-03-18T16:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/glpi-project/glpi
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 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

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();
    }