CVE-2023-4005 (PHP, published 2023-07-31T01:15Z)
Description: value: Insufficient Session Expiration in GitHub repository fossbilling/fossbilling prior to 0.5.5.
Severity: nan (CVSS v3 base score: 9.8)
Weakness: CWE-613 \u2014 Insufficient Session Expiration
According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
Repository: https://github.com/fossbilling/fossbilling
Files changed:
src/modules/Client/Api/Admin.php
src/modules/Profile/Api/Admin.php
src/modules/Profile/Api/Client.php
src/modules/Profile/Service.php
src/modules/Staff/Service.php
tests/modules/Client/Api/AdminTest.php
tests/modules/Staff/ServiceTest.php
Commit message: Invalidate existing sessions during PW reset (#1435)



Vulnerable code:
        if(!$this->di['password']->verify($data['current_password'], $staff->pass)) {
        if(!$this->di['password']->verify($data['current_password'], $client->pass)) {


Fixed code:
        $profileService = $this->di['mod_service']('profile');
        $profileService->invalidateSessions('client', $data['id']);

        if (!$this->di['password']->verify($data['current_password'], $staff->pass)) {
        $this->getService()->invalidateSessions();

    /**
     * Used to destroy / invalidate all existing sessions for a given user
     * @param array $data An array with the options.
     *                    The array can contain the following sub-keys:
     *                    - string|null $data['type'] The user type (admin or staff) (optional).
     *                    - id|null $data['id'] The session ID (optional).
     * @return bool 
     */
    public function destroy_sessions(array $data): bool
    {
        $data['type'] ??= null;
        $data['id'] ??= null;
        return $this->getService()->invalidateSessions($data['type'], $data['id']);
    }
        if (!$this->di['password']->verify($data['current_password'], $client->pass)) {
        $this->getService()->invalidateSessions();

    /**
     * Used to destroy / invalidate all existing sessions for the current client
     * @return bool 
     */
    public function destroy_sessions(array $data): bool
    {
        return $this->getService()->invalidateSessions();
    }

    public function invalidateSessions(?string $type = null, ?int $id = null): bool
    {
        if (empty($type)) {
            $auth = new \Box_Authorization($this->di);
            if ($auth->isAdminLoggedIn()) {
                $type = 'admin';
            } elseif ($auth->isClientLoggedIn()) {
                $type = 'client';
            } else {
                throw new \Box_Exception("Unable to invalidate sessions, nobody is logged in");
            }
        }

        if (empty($id)) {
            switch ($type) {
                case 'admin':
                    $admin = $this->di['session']->get('admin');
                    $id = $admin['id'];
                    break;
                case 'client':
                    $id = $this->di['session']->get('client_id');
                    break;
            }
        }

        if ($type !== 'admin' && $type !== 'client') {
            throw new \Box_Exception("Unable to invalidate sessions, an invalid type was used");
        }

        $sessions = $this->getSessions();
        foreach ($sessions as $session) {
            $this->deleteSessionIfMatching($session, $type, $id);
        }

        return true;
    }

    private function getSessions(): array
    {
        $query = 'SELECT * FROM session WHERE content IS NOT NULL AND content <> ""';
        $sessions = $this->di['db']->getAll($query);
        return $sessions;
    }

    private function deleteSessionIfMatching(array $session, string $type, int $id): void
    {
        // Decode the data for the current session and then verify it is for the selected type
        $data = base64_decode($session['content']);
        $stringStart = ($type === 'admin') ? 'admin|' : 'client_id|';
        if (!str_starts_with($data, $stringStart)) {
            return;
        }

        // Now we strip off the starting portion so we can unserialize the data
        $data = str_replace($stringStart, '', $data);

        // Finally, perform the check depending on what type of session we are looking for and trash it if it's a match
        if ($type === 'admin') {
            $dataArray = unserialize($data);
            if ($dataArray['id'] === $id) {
                $this->trashSessionByArray($session);
            }
        } else {
            if (unserialize($data) === $id) {
                $this->trashSessionByArray($session);
            }
        }
    }

    private function trashSessionByArray(array $session): void
    {
        $bean = $this->di['db']->dispense('session');
        $bean->import($session);
        $this->di['db']->trash($bean);
    }
        $profileService = $this->di['mod_service']('profile');
        $profileService->invalidateSessions('admin', $model->id);

        $profileService = $this->getMockBuilder('\Box\Mod\Profile\Service')->getMock();

        $di['mod_service'] = $di->protect(function () use ($profileService) {
            return $profileService;
        });
        
        $profileService = $this->getMockBuilder('\Box\Mod\Profile\Service')->getMock();
        $di['mod_service'] = $di->protect(function () use ($profileService) {
            return $profileService;
        });