CVE-2023-4005 (published 2023-07-31T01:15Z)

Insufficient Session Expiration in GitHub repository fossbilling/fossbilling prior to 0.5.5.

CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-613
cwe_name: Insufficient Session Expiration
description: According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
extended_description: 
            
            
            
         
         

Fix information:
repo_url: https://github.com/fossbilling/fossbilling
commit_details: repo_url: https://github.com/fossbilling/fossbilling
author: Belle Aerni
author_date: 2023-07-20 05:23:44-07:00
msg: Invalidate existing sessions during PW reset (#1435)
repository: repo_url: https://github.com/fossbilling/fossbilling
repo_name: FOSSBilling/FOSSBilling
description: Empower your hosting business with FOSSBilling, the free and open-source solution for efficient billing and client management.
repo_language: PHP
owner: fossbilling
file_changes: filename: Admin.php
change_type: ModificationType.MODIFY
diff: @@ -354,6 +354,9 @@ class Admin extends \Api_Abstract
         $client->updated_at = date('Y-m-d H:i:s');
         $this->di['db']->store($client);
 
+        $profileService = $this->di['mod_service']('profile');
+        $profileService->invalidateSessions('client', $data['id']);
+
         $this->di['events_manager']->fire(['event' => 'onAfterAdminClientPasswordChange', 'params' => ['id' => $client->id, 'password' => $data['password']]]);
 
         $this->di['logger']->info('Changed client #%s password', $client->id);

programming_language: PHP
filename: Admin.php
change_type: ModificationType.MODIFY
diff: @@ -116,10 +116,26 @@ class Admin extends \Api_Abstract
 
         $staff = $this->getIdentity();
 
-        if(!$this->di['password']->verify($data['current_password'], $staff->pass)) {
+        if (!$this->di['password']->verify($data['current_password'], $staff->pass)) {
             throw new \Exception('Current password incorrect');
         }
 
+        $this->getService()->invalidateSessions();
         return $this->getService()->changeAdminPassword($staff, $data['new_password']);
     }
+
+    /**
+     * 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']);
+    }
 }

programming_language: PHP
filename: Client.php
change_type: ModificationType.MODIFY
diff: @@ -120,10 +120,11 @@ class Client extends \Api_Abstract
 
         $client = $this->getIdentity();
 
-        if(!$this->di['password']->verify($data['current_password'], $client->pass)) {
+        if (!$this->di['password']->verify($data['current_password'], $client->pass)) {
             throw new \Exception('Current password incorrect');
         }
 
+        $this->getService()->invalidateSessions();
         return $this->getService()->changeClientPassword($client, $data['new_password']);
     }
 
@@ -136,4 +137,13 @@ class Client extends \Api_Abstract
     {
         return $this->getService()->logoutClient();
     }
+
+    /**
+     * Used to destroy / invalidate all existing sessions for the current client
+     * @return bool 
+     */
+    public function destroy_sessions(array $data): bool
+    {
+        return $this->getService()->invalidateSessions();
+    }
 }

programming_language: PHP
filename: Service.php
change_type: ModificationType.MODIFY
diff: @@ -222,4 +222,80 @@ class Service implements InjectionAwareInterface
 
         return true;
     }
+
+    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);
+    }
 }

programming_language: PHP
filename: Service.php
change_type: ModificationType.MODIFY
diff: @@ -443,6 +443,9 @@ class Service implements InjectionAwareInterface
         $model->updated_at = date('Y-m-d H:i:s');
         $this->di['db']->store($model);
 
+        $profileService = $this->di['mod_service']('profile');
+        $profileService->invalidateSessions('admin', $model->id);
+
         $this->di['events_manager']->fire(['event' => 'onAfterAdminStaffPasswordChange', 'params' => ['id' => $model->id]]);
 
         $this->di['logger']->info('Changed staff member %s password', $model->id);

programming_language: PHP
filename: AdminTest.php
change_type: ModificationType.MODIFY
diff: @@ -449,6 +449,8 @@ class AdminTest extends \BBTestCase
             ->method('hashIt')
             ->with($data['password']);
 
+        $profileService = $this->getMockBuilder('\Box\Mod\Profile\Service')->getMock();
+
         $di                   = new \Pimple\Container();
         $di['db']             = $dbMock;
         $di['events_manager'] = $eventMock;
@@ -459,7 +461,9 @@ class AdminTest extends \BBTestCase
             ->method('checkRequiredParamsForArray')
             ->will($this->returnValue(null));
         $di['validator'] = $validatorMock;
-
+        $di['mod_service'] = $di->protect(function () use ($profileService) {
+            return $profileService;
+        });
 
         $admin_Client = new \Box\Mod\Client\Api\Admin();
         $admin_Client->setDi($di);

programming_language: PHP
filename: ServiceTest.php
change_type: ModificationType.MODIFY
diff: @@ -1027,12 +1027,17 @@ class ServiceTest extends \BBTestCase
         $passwordMock->expects($this->atLeastOnce())
             ->method('hashIt')
             ->with($plainTextPassword);
+        
+        $profileService = $this->getMockBuilder('\Box\Mod\Profile\Service')->getMock();
 
         $di                   = new \Pimple\Container();
         $di['events_manager'] = $eventsMock;
         $di['logger']         = $logMock;
         $di['db']             = $dbMock;
         $di['password']       = $passwordMock;
+        $di['mod_service'] = $di->protect(function () use ($profileService) {
+            return $profileService;
+        });
 
         $service = new \Box\Mod\Staff\Service();
         $service->setDi($di);

programming_language: PHP