CVE-2023-3394: Session Fixation (CWE-384)
value: Session Fixation in GitHub repository fossbilling/fossbilling prior to 0.5.1.
CWE description: Authenticating a user, or otherwise establishing a new user session, without invalidating any existing session identifier gives an attacker the opportunity to steal authenticated sessions.
CVSS v3 base score: 5.4
Published: 2023-06-23T19:15Z
Repository: https://github.com/fossbilling/fossbilling
Language: PHP
Files changed: README.md, SECURITY.md, cspell.json, src/di.php, src/install/sql/content.sql, src/install/sql/structure.sql, src/library/FOSSBilling/Fingerprint.php, src/library/FOSSBilling/Session.php, src/library/FOSSBilling/UpdatePatcher.php, src/modules/Client/Api/Guest.php, src/modules/Cron/Service.php, src/modules/Profile/Api/Admin.php, src/modules/Profile/Service.php, src/modules/Staff/Service.php, tests/integration/bb-library/Box/DiTest.php, tests/modules/Cart/ServiceTest.php, tests/modules/Client/Api/AdminTest.php, tests/modules/Client/Api/GuestTest.php, tests/modules/Cron/ServiceTest.php, tests/modules/Profile/Api/AdminTest.php, tests/modules/Profile/ServiceTest.php, tests/modules/Staff/ServiceTest.php, tests/modules/System/ServiceTest.php
Commit date: 2023-06-19 04:14:29 -0700
Commit message: Replace Box_Session and improve session handling (#1332)

* Replace Box_Session and improve session handling

* Improve weights, capitalize class name correctly

* Make the tests pass, update the SQL structure

* Tweak to the fingerprint class, fix more tests

* Regenerate session IDs when logging in

* Improve readability

* Update ServiceTest.php

* Tweak weights and added Huntr badge to the readme

* Improve readability, add PHPDocs, and updated weight

* Update SECURITY.md
Diff: diff --git a/README.md b/README.md
index 72778a39c..67f727503 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,12 @@
 [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
 [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) 
 [![CodeFactor](https://www.codefactor.io/repository/github/FOSSBilling/FOSSBilling/badge)](https://www.codefactor.io/repository/github/fossbilling/fossbilling)
 [![Financial Contributors](https://opencollective.com/FOSSBilling/tiers/badge.svg?color=brightgreen)](https://opencollective.com/fossbilling)
 [![Crowdin](https://badges.crowdin.net/e/c70c78b4ab1e71424ce53dcf6bca9b12/localized.svg)](https://fossbilling.crowdin.com/FOSSBilling)
+[![huntr](https://cdn.huntr.dev/huntr_security_badge_mono.svg)](https://huntr.dev/repos/fossbilling/fossbilling/)
+
 </div>
 
 > **Warning**
 > FOSSBilling is under active development but is currently very much beta software. This means that there may be stability or security issues and it is not yet recommended for use in active production environments! Also please be aware that we are not currently strictly following SemVer, and there may be breaking changes at any time. Be careful and make sure you read changelogs before updates!
 
diff --git a/SECURITY.md b/SECURITY.md
index 2f5ef5755..872212a45 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,11 +2,12 @@
 
 ## Supported Versions
 
 | Version | Supported          |
 | ------- | ------------------ |
-| 0.4.x   | :white_check_mark: |
+| 0.5.x   | :white_check_mark: |
+| 0.4.x   | :x: |
 | 0.3.x   | :x: |
 | 0.2.x   | :x: |
 | 0.1.x   | :x: |
 
 ## Reporting Vulnerabilities
diff --git a/cspell.json b/cspell.json
index fd16f14f4..88c4245fb 100644
--- a/cspell.json
+++ b/cspell.json
@@ -224,11 +224,10 @@
         "mbstring",
         "srcset",
         "gugl",
         "cclogin",
         "CURDATE",
-        "BOXCLR",
         "vatnum",
         "caid",
         "ECB's",
         "onkeyup",
         "DYSY",
@@ -336,11 +335,13 @@
         "FileCacheExcption",
         "Autoloader",
         "autoloader",
         "qwertyuiopasdfghjklzxccvbnm",
         "QWERTYUIOPASDFGHJKLZXCVBNM",
-        "subkeys"
+        "subkeys",
+        "httponly",
+        "samesite"
     ],
     "ignorePaths": [
         "tests/**",
         "node_modules/**",
         "src/themes/*/assets/**",
diff --git a/src/di.php b/src/di.php
index 8d74158fa..99f11da56 100644
--- a/src/di.php
+++ b/src/di.php
@@ -222,19 +222,24 @@ $di['events_manager'] = function () use ($di) {
 /*
  * Creates a new session, applying specified security rules depending on the config.php settings.
  *
  * @param void
  *
- * @return \Box_Session
+ * @return \FOSSBilling\Session
  */
 $di['session'] = function () use ($di) {
     $handler = new PdoSessionHandler($di['pdo']);
-    $mode = (isset($di['config']['security']['mode'])) ? $di['config']['security']['mode'] : 'strict';
-    $lifespan = (isset($di['config']['security']['cookie_lifespan'])) ? $di['config']['security']['cookie_lifespan'] : 7200;
-    $secure = (isset($di['config']['security']['force_https'])) ? $di['config']['security']['force_https'] : true;
 
-    return new Box_Session($handler, $mode, $lifespan, $secure);
+    $mode = $di['config']['security']['mode'] ?? 'strict';
+    $lifespan = $di['config']['security']['cookie_lifespan'] ?? 7200;
+    $secure = $di['config']['security']['force_https'] ?? true;
+
+    $session = new \FOSSBilling\Session($handler, $mode, $lifespan, $secure);
+    $session->setDi($di);
+    $session->setupSession();
+
+    return $session;
 };
 
 /*
  *
  * @param void
diff --git a/src/install/sql/content.sql b/src/install/sql/content.sql
index 879178977..29b9580a6 100644
--- a/src/install/sql/content.sql
+++ b/src/install/sql/content.sql
@@ -314,11 +314,11 @@ UNLOCK TABLES;
 LOCK TABLES `setting` WRITE;
 /*!40000 ALTER TABLE `setting` DISABLE KEYS */;
 
 INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
 VALUES
-	(1,'last_patch','33',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
+	(1,'last_patch','34',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
 	(2,'company_name','Company Name',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
 	(3,'company_email','company@email.com',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
 	(4,'company_signature','FOSSBilling.org - Client Management, Invoice and Support Software',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
 	(5,'company_logo','themes/huraga/assets/img/logo.svg',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
 	(6,'company_logo_dark','themes/huraga/assets/img/logo_white.svg',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
diff --git a/src/install/sql/structure.sql b/src/install/sql/structure.sql
index 8549ff808..4294b36ca 100644
--- a/src/install/sql/structure.sql
+++ b/src/install/sql/structure.sql
@@ -1143,10 +1143,11 @@ CREATE TABLE `service_membership` (
 /*!40101 SET character_set_client = utf8 */;
 CREATE TABLE `session` (
   `id` varchar(32) NOT NULL DEFAULT '',
   `modified_at` int(11) DEFAULT NULL,
   `content` text,
+  `fingerprint` text,
   UNIQUE KEY `unique_id` (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 /*!40101 SET character_set_client = @saved_cs_client */;
 
 --
diff --git a/src/library/Box/Session.php b/src/library/Box/Session.php
deleted file mode 100644
index a1aac2019..000000000
--- a/src/library/Box/Session.php
+++ /dev/null
@@ -1,88 +0,0 @@
-<?php
-/**
- * Copyright 2022-2023 FOSSBilling
- * Copyright 2011-2021 BoxBilling, Inc.
- * SPDX-License-Identifier: Apache-2.0
- *
- * @copyright FOSSBilling (https://www.fossbilling.org)
- * @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
- */
-
-class Box_Session
-{
-    public function setRandomId()
-    {
-        $id = random_bytes(64);
-        session_id($id);
-    }
-
-
-    public function __construct($handler, $securityMode = 'regular', $cookieLifespan = 7200, $secure = true)
-    {
-        if (!headers_sent()) {
-            session_set_save_handler(
-                array($handler, 'open'),
-                array($handler, 'close'),
-                array($handler, 'read'),
-                array($handler, 'write'),
-                array($handler, 'destroy'),
-                array($handler, 'gc')
-            );
-        }
-        if (php_sapi_name() !== 'cli') {
-            $currentCookieParams = session_get_cookie_params();
-            $currentCookieParams["httponly"] = true;
-            $currentCookieParams["lifetime"] = $cookieLifespan;
-            $currentCookieParams["secure"] = $secure;
-
-            if ($securityMode == 'strict') {
-                session_set_cookie_params([
-                    'lifetime' => $currentCookieParams["lifetime"],
-                    'path' => $currentCookieParams["path"],
-                    'domain' => $currentCookieParams["domain"],
-                    'secure' => $currentCookieParams["secure"],
-                    'httponly' => $currentCookieParams["httponly"],
-                    'samesite' => 'Strict'
-                ]);
-                // TODO: Adjust the DB to support 64 character long session IDs
-                // Currently adjusting it causing issues within this file: https://github.com/FOSSBilling/FOSSBilling/blob/main/src/library/PdoSessionHandler.php
-                //$this->setRandomId();
-            } else {
-                session_set_cookie_params(
-                    $currentCookieParams["lifetime"],
-                    $currentCookieParams["path"],
-                    $currentCookieParams["domain"],
-                    $currentCookieParams["secure"],
-                    $currentCookieParams["httponly"]
-                );
-            }
-
-            session_start();
-        }
-    }
-
-    public function getId()
-    {
-        return session_id();
-    }
-
-    public function delete($key)
-    {
-        unset($_SESSION[$key]);
-    }
-
-    public function get($key)
-    {
-        return isset($_SESSION[$key]) ? $_SESSION[$key] : null;
-    }
-
-    public function set($key, $value)
-    {
-        $_SESSION[$key] = $value;
-    }
-
-    public function destroy()
-    {
-        session_destroy();
-    }
-}
diff --git a/src/library/FOSSBilling/Fingerprint.php b/src/library/FOSSBilling/Fingerprint.php
new file mode 100644
index 000000000..d5093858c
--- /dev/null
+++ b/src/library/FOSSBilling/Fingerprint.php
@@ -0,0 +1,163 @@
+<?php
+declare(strict_types=1);
+/**
+ * Copyright 2022-2023 FOSSBilling
+ * Copyright 2011-2021 BoxBilling, Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * @copyright FOSSBilling (https://www.fossbilling.org)
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
+ */
+
+namespace FOSSBilling;
+
+class Fingerprint
+{
+    private array $fingerprintProperties;
+
+    public function __construct()
+    {
+        $agentDetails = $this->extractAgentInfo();
+
+        /**
+         * Sets up the fingerprint info for the existing request.
+         * 'weight' is used to weigh specific parameters.
+         *      Example: The agent string has a weight of 2, one failure from it equal as 2 failures of other properties.
+         *      By doing this, we can prevent minor changes such as a browser update from requiring the user to re-authenticate.
+         *      But it does mean that if the that property and one-or-two other ones fail, the user will need to re-authenticate.
+         */
+        $this->fingerprintProperties = [
+            'agentString' => [
+                'source' => $agentDetails['userAgent'],
+                'weight' => 2,
+            ],
+            'browser' => [
+                'source' => $agentDetails['browser'],
+                'weight' => 100, // Always fail if this doesn't match.
+            ],
+            'browserVersion' => [
+                'source' => $agentDetails['browserVersion'],
+                'weight' => 1,
+            ],
+            'os' => [
+                'source' => $agentDetails['os'],
+                'weight' => 100, // Always fail if this doesn't match.
+            ],
+            'ip' => [
+                'source' => $_SERVER['REMOTE_ADDR'] ?? '',
+                'weight' => 3,
+            ],
+            'referrer' => [
+                'source' => $_SERVER['HTTP_REFERER'] ?? '',
+                'weight' => 1,
+            ],
+            'forwardedFor' => [
+                'source' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '',
+                'weight' => 3,
+            ],
+            'language' => [
+                'source' => $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '',
+                'weight' => 2,
+            ],
+            'encoding' => [
+                'source' => $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '',
+                'weight' => 1,
+            ],
+            'upgradeRequests' => [
+                'source' => $_SERVER['HTTP_UPGRADE_INSECURE_REQUESTS'] ?? '',
+                'weight' => 1,
+            ],
+        ];
+    }
+
+    /**
+     * Generates a fingerprint for the device that made the request to the server
+     */
+    public function fingerprint(): array
+    {
+        $fingerprint = [];
+
+        foreach ($this->fingerprintProperties as $name => $properties) {
+            if (!empty($properties['source'])) {
+                $fingerprint[$name] = hash('md5', $properties['source']);
+            }
+        }
+
+        return $fingerprint;
+    }
+
+    /**
+     * Compares a provided fingerprint against one generated for the device that made the request to the server.
+     * This function creates a baseline "score" with the total of properties in the fingerprint. The final score must be at least half of the baseline.
+     *      - Each property can define a weight. For example, if the IP address doesn't match and the weight is set to 3, 3 will be selected from the total.
+     *          - This means with a total of 9 properties, the IP address being wrong would effectively be weighted as 3 properties and only two more differing properties will make it fail the check.
+     *      - If any property is found in one of the fingerprints and not the other, the baseline is incremented and the final score is decreased by it's weight.
+     */
+    public function checkFingerprint(array $fingerprint): bool
+    {
+        $itemCount = 0;
+        $scoreSubtract = 0;
+
+        foreach ($this->fingerprintProperties as $name => $properties) {
+            $exitsInFingerprint = array_key_exists($name, $fingerprint);
+            $exitsInCurrentFingerprint = !empty($properties['source']);
+
+            if ((!$exitsInFingerprint && $exitsInCurrentFingerprint) || ($exitsInFingerprint && !$exitsInCurrentFingerprint)) {
+                //The property exists in one fingerprint and not the other, so we increment the total count and deduct from the score.
+                $itemCount++;
+                $scoreSubtract += $properties['weight'];
+            } elseif (!$exitsInFingerprint && !$exitsInCurrentFingerprint) {
+                // Do nothing in this case, as the property isn't in either fingerprint.
+            } else {
+                $itemCount++;
+                $hashedData = hash('md5', $properties['source']);
+
+                if ($fingerprint[$name] !== $hashedData) {
+                    $scoreSubtract += $properties['weight'];
+                }
+            }
+        }
+
+        // Remove the total score from the total number of items. The final score must be at least half the number of properties in order for the fingerprint to be considered valid.
+        $finalScore = $itemCount - $scoreSubtract;
+        return $finalScore >= ($itemCount / 2);
+    }
+
+    private function extractAgentInfo(): array
+    {
+        $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
+
+        // Extract the browser name
+        if (preg_match('/(?:Chrome|CriOS)\/([0-9\.]+)/', $userAgent, $matches)) {
+            $browser = 'Chrome';
+            $version = $matches[1];
+        } elseif (preg_match('/Firefox\/([0-9\.]+)/', $userAgent, $matches)) {
+            $browser = 'Firefox';
+            $version = $matches[1];
+        } elseif (preg_match('/Safari\/([0-9\.]+)/', $userAgent, $matches)) {
+            $browser = 'Safari';
+            $version = $matches[1];
+        } else {
+            $browser = 'Unknown';
+            $version = 'Unknown';
+        }
+
+        // Extract the operating system
+        if (preg_match('/Windows NT ([0-9\.]+)/', $userAgent, $matches)) {
+            $os = 'Windows NT ' . $matches[1];
+        } elseif (preg_match('/Mac OS X ([0-9_]+)/', $userAgent, $matches)) {
+            $os = 'Mac OS X';
+        } elseif (preg_match('/Linux/', $userAgent)) {
+            $os = 'Linux';
+        } else {
+            $os = 'Unknown';
+        }
+
+        return [
+            'browser' => $browser,
+            'browserVersion' => $version,
+            'os' => $os,
+            'userAgent' => $userAgent,
+        ];
+    }
+}
diff --git a/src/library/FOSSBilling/Session.php b/src/library/FOSSBilling/Session.php
new file mode 100644
index 000000000..db2561541
--- /dev/null
+++ b/src/library/FOSSBilling/Session.php
@@ -0,0 +1,155 @@
+<?php
+declare(strict_types=1);
+/**
+ * Copyright 2022-2023 FOSSBilling
+ * Copyright 2011-2021 BoxBilling, Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * @copyright FOSSBilling (https://www.fossbilling.org)
+ * @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
+ */
+
+namespace FOSSBilling;
+
+class Session implements \FOSSBilling\InjectionAwareInterface
+{
+    private ?\Pimple\Container $di;
+    private \PdoSessionHandler $handler;
+    private string $securityMode;
+    private int $cookieLifespan;
+    private bool $secure;
+
+    public function setDi(\Pimple\Container|null $di): void
+    {
+        $this->di = $di;
+    }
+
+    public function getDi(): ?\Pimple\Container
+    {
+        return $this->di;
+    }
+
+    public function __construct(\PdoSessionHandler $handler, string $securityMode = 'regular', int $cookieLifespan = 7200, bool $secure = true)
+    {
+        $this->handler = $handler;
+        $this->securityMode = $securityMode;
+        $this->cookieLifespan = $cookieLifespan;
+        $this->secure = $secure;
+    }
+
+    public function setupSession()
+    {
+        if (php_sapi_name() === 'cli') {
+            return;
+        }
+
+        $this->canUseSession();
+
+        if (!headers_sent()) {
+            session_set_save_handler(
+                [$this->handler, 'open'],
+                [$this->handler, 'close'],
+                [$this->handler, 'read'],
+                [$this->handler, 'write'],
+                [$this->handler, 'destroy'],
+                [$this->handler, 'gc']
+            );
+        }
+
+        $currentCookieParams = session_get_cookie_params();
+        $currentCookieParams["httponly"] = true;
+        $currentCookieParams["lifetime"] = $this->cookieLifespan;
+        $currentCookieParams["secure"] = $this->secure;
+
+        $cookieParams = [
+            'lifetime' => $currentCookieParams["lifetime"],
+            'path' => $currentCookieParams["path"],
+            'domain' => $currentCookieParams["domain"],
+            'secure' => $currentCookieParams["secure"],
+            'httponly' => $currentCookieParams["httponly"]
+        ];
+
+        if ($this->securityMode == 'strict') {
+            $cookieParams['samesite'] = 'Strict';
+        }
+
+        session_set_cookie_params($cookieParams);
+        session_start();
+
+        $this->updateFingerprint();
+    }
+
+    public function getId(): string
+    {
+        return session_id();
+    }
+
+    public function delete(string $key): void
+    {
+        unset($_SESSION[$key]);
+    }
+
+    public function get(string $key): mixed
+    {
+        return $_SESSION[$key] ?? null;
+    }
+
+    public function set(string $key, mixed $value): void
+    {
+        $_SESSION[$key] = $value;
+    }
+
+    public function destroy(string $type = ''): bool
+    {
+        switch ($type) {
+            case 'admin':
+                $this->delete('admin');
+                break;
+            case 'client':
+                $this->delete('client');
+                $this->delete('client_id');
+                break;
+        }
+
+        return session_destroy();
+    }
+
+    /**
+     * Checks both the fingerprint and age of the current session to see if it can be used.
+     * If the session can't be used, it's destroyed from the database, forcing a new one to be created.
+     */
+    private function canUseSession():void
+    {
+        if (empty($_COOKIE['PHPSESSID'])) {
+            return;
+        }
+
+        $sessionID = $_COOKIE['PHPSESSID'];
+        $maxAge = time() - $this->di['config']['security']['cookie_lifespan'];
+
+        $fingerprint = new \FOSSBilling\Fingerprint;
+        $session = $this->di['db']->findOne('session', 'id = :id', [':id' => $sessionID]);
+
+        if (empty($session->fingerprint)) {
+            return;
+        }
+
+        if (!$fingerprint->checkFingerprint(json_decode($session->fingerprint, true)) || $session->modified_at <= $maxAge) {
+            $this->di['db']->trash($session);
+            unset($_COOKIE['PHPSESSID']);
+        }
+    }
+
+    /**
+     * Depending on the specifics, this will either set or update the fingerprint associated with the current session.
+     */
+    private function updateFingerprint():void
+    {
+        $sessionID = $_COOKIE['PHPSESSID'] ?? session_id();
+        $session = $this->di['db']->findOne('session', 'id = :id', [':id' => $sessionID]);
+
+        $fingerprint = new \FOSSBilling\Fingerprint;
+        $session->fingerprint = json_encode($fingerprint->fingerprint());
+        $this->di['db']->store($session);
+    }
+}
diff --git a/src/library/FOSSBilling/UpdatePatcher.php b/src/library/FOSSBilling/UpdatePatcher.php
index b06a3aa6d..afa8cec84 100644
--- a/src/library/FOSSBilling/UpdatePatcher.php
+++ b/src/library/FOSSBilling/UpdatePatcher.php
@@ -274,11 +274,16 @@ class UpdatePatcher implements InjectionAwareInterface
                 // @see https://github.com/FOSSBilling/FOSSBilling/pull/1184
                 $fileActions = [
                     __DIR__ . DIRECTORY_SEPARATOR . 'library' . DIRECTORY_SEPARATOR . 'FileCache.php' => 'unlink',
                 ];
                 $this->executeFileActions($fileActions);
-            }
+            },
+            34 => function() {
+                // Adds the new "fingerprint" to the session table, to allow us to fingerprint devices and help prevent against attacks such as session hijacking.
+                $q = "ALTER TABLE session ADD fingerprint TEXT;";
+                $this->executeSql($q);
+            },
         ];
         ksort($patches, SORT_NATURAL);
 
         return array_filter($patches, function($key) use ($patchLevel) {
             return $key > $patchLevel;
diff --git a/src/modules/Client/Api/Guest.php b/src/modules/Client/Api/Guest.php
index 084fbf0d8..2be19406e 100644
--- a/src/modules/Client/Api/Guest.php
+++ b/src/modules/Client/Api/Guest.php
@@ -134,18 +134,13 @@ class Guest extends \Api_Abstract
         if (!$client instanceof \Model_Client) {
             $this->di['events_manager']->fire(['event' => 'onEventClientLoginFailed', 'params' => $event_params]);
             throw new \Box_Exception('Please check your login details.', [], 401);
         }
 
-        if (isset($data['remember'])) {
-            $email = $data['email'];
-            $cookie_time = (3600 * 24 * 30); // 30 days
-            setcookie('BOXCLR', 'e=' . base64_encode($email) . '&p=' . base64_encode($client->pass), time() + $cookie_time, '/');
-        }
-
         $this->di['events_manager']->fire(['event' => 'onAfterClientLogin', 'params' => ['id' => $client->id, 'ip' => $this->ip]]);
 
+        session_regenerate_id();
         $result = $service->toSessionArray($client);
         $this->di['session']->set('client_id', $client->id);
 
         $this->di['logger']->info('Client #%s logged in', $client->id);
 
diff --git a/src/modules/Cron/Service.php b/src/modules/Cron/Service.php
index 31aa4a327..c30698f7c 100644
--- a/src/modules/Cron/Service.php
+++ b/src/modules/Cron/Service.php
@@ -68,10 +68,13 @@ class Service
 
         $create = (APPLICATION_ENV == 'production');
         $ss = $this->di['mod_service']('system');
         $ss->setParamValue('last_cron_exec', date('Y-m-d H:i:s'), $create);
 
+        $this->clearOldSessions();
+        $this->di['logger']->setChannel('cron')->info('Cleared outdated sessions from the database');
+
         $this->di['events_manager']->fire(['event' => 'onAfterAdminCronRun']);
 
         $this->di['logger']->setChannel('cron')->info('Finished executing cron jobs');
 
         return true;
@@ -109,6 +112,17 @@ class Service
         $t1 = new \DateTime($this->getLastExecutionTime());
         $t2 = new \DateTime('-6min');
 
         return $t1 < $t2;
     }
+
+    private function clearOldSessions()
+    {
+        $sessions = $this->di['db']->findAll('session');
+        foreach ($sessions as $session){
+            $maxAge = time() - $this->di['config']['security']['cookie_lifespan'];
+            if($session->modified_at <= $maxAge){
+                $this->di['db']->trash($session);
+            }
+        }
+    }
 }
diff --git a/src/modules/Profile/Api/Admin.php b/src/modules/Profile/Api/Admin.php
index 51efa1af8..257624da4 100644
--- a/src/modules/Profile/Api/Admin.php
+++ b/src/modules/Profile/Api/Admin.php
@@ -50,11 +50,11 @@ class Admin extends \Api_Abstract
      * @return bool
      */
     public function logout()
     {
         unset($_COOKIE['BOXADMR']);
-        $this->di['session']->delete('admin');
+        $this->di['session']->destroy('admin');
         $this->di['logger']->info('Admin logged out');
 
         return true;
     }
 
diff --git a/src/modules/Profile/Service.php b/src/modules/Profile/Service.php
index 4dcbd812e..50de535d8 100644
--- a/src/modules/Profile/Service.php
+++ b/src/modules/Profile/Service.php
@@ -215,15 +215,11 @@ class Service implements InjectionAwareInterface
         return true;
     }
 
     public function logoutClient()
     {
-        if ($_COOKIE) { // testing env fix
-            setcookie('BOXCLR', '', time() - 3600, '/');
-        }
-        $this->di['session']->delete('client');
-        $this->di['session']->delete('client_id');
+        $this->di['session']->destroy('client');
         $this->di['logger']->info('Logged out');
 
         return true;
     }
 }
diff --git a/src/modules/Staff/Service.php b/src/modules/Staff/Service.php
index 671dc01ac..af6d74807 100644
--- a/src/modules/Staff/Service.php
+++ b/src/modules/Staff/Service.php
@@ -48,10 +48,11 @@ class Service implements InjectionAwareInterface
             'email' => $model->email,
             'name' => $model->name,
             'role' => $model->role,
         ];
 
+        session_regenerate_id();
         $this->di['session']->set('admin', $result);
 
         $this->di['logger']->info(sprintf('Staff member %s logged in', $model->id));
 
         return $result;
@@ -496,12 +497,10 @@ class Service implements InjectionAwareInterface
         $admin->updated_at = date('Y-m-d H:i:s');
 
         $newId = $this->di['db']->store($admin);
 
         $this->di['logger']->info('Main administrator %s account created', $admin->email);
-        $this->_sendMail($admin, $data['password']);
-
         $data['remember'] = true;
 
         return $newId;
     }
 
@@ -647,36 +646,10 @@ class Service implements InjectionAwareInterface
         $this->di['db']->trash($model);
 
         return true;
     }
 
-    protected function _sendMail($admin, $admin_pass)
-    {
-        $admin_name = $admin->name;
-        $admin_email = $admin->email;
-
-        $client_url = $this->di['url']->link('/');
-        $admin_url = $this->di['url']->adminLink('/');
-
-        $content = "Hello, $admin_name. " . PHP_EOL;
-        $content .= 'You have successfully installed FOSSBilling at ' . BB_URL . PHP_EOL;
-        $content .= 'Access the client area at: ' . $client_url . PHP_EOL;
-        $content .= 'Access the admin area at: ' . $admin_url . ' with login details:' . PHP_EOL;
-        $content .= 'Email: ' . $admin_email . PHP_EOL;
-        $content .= 'Password: ' . $admin_pass . PHP_EOL . PHP_EOL;
-
-        $content .= 'Read the FOSSBilling documentation to get started https://fossbilling.org/docs' . PHP_EOL;
-        $content .= 'Thank you for using FOSSBilling.' . PHP_EOL;
-
-        $subject = sprintf('FOSSBilling is ready at "%s"', BB_URL);
-
-        $systemService = $this->di['mod_service']('system');
-        $from = $systemService->getParamValue('company_email');
-        $emailService = $this->di['mod_service']('Email');
-        $emailService->sendMail($admin_email, $from, $subject, $content);
-    }
-
     public function authorizeAdmin($email, $plainTextPassword)
     {
         $model = $this->di['db']->findOne('Admin', 'email = ? AND status = ?', [$email, \Model_Admin::STATUS_ACTIVE]);
         if (null == $model) {
             return null;
diff --git a/tests/integration/bb-library/Box/DiTest.php b/tests/integration/bb-library/Box/DiTest.php
index 1b01ca030..b13d66885 100644
--- a/tests/integration/bb-library/Box/DiTest.php
+++ b/tests/integration/bb-library/Box/DiTest.php
@@ -19,11 +19,11 @@ class DiTest extends PHPUnit\Framework\TestCase
 
         $this->assertInstanceOf('Box_Pagination', $di['pager']);
         $this->assertInstanceOf('Box_Url', $di['url']);
         $this->assertInstanceOf('Box_EventManager', $di['events_manager']);
 
-        $this->assertInstanceOf('\Box_Session', $di['session']);
+        $this->assertInstanceOf('\FOSSBilling\Session', $di['session']);
         $this->assertInstanceOf('Box_Authorization', $di['auth']);
         $this->assertInstanceOf('Twig\Environment', $di['twig']);
         $this->assertInstanceOf('\FOSSBilling\Tools', $di['tools']);
         $this->assertInstanceOf('\FOSSBilling\Validate', $di['validator']);
 
diff --git a/tests/library/Box/Box_SessionTest.php b/tests/library/Box/Box_SessionTest.php
deleted file mode 100644
index 0436a49b8..000000000
--- a/tests/library/Box/Box_SessionTest.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-/**
- * @group Core
- */
-class Box_SessionTest extends PHPUnit\Framework\TestCase
-{
-    public function testSession()
-    {
-        $mock = $this->getMockBuilder("\Box_Session")
-            ->disableOriginalConstructor()
-            ->getMock();
-
-        $mock->expects($this->atLeastOnce())
-            ->method("getId")
-            ->will($this->returnValue("rrcpqo7tkjh14d2vmf0car64k7"));
-
-        $mock->expects($this->atLeastOnce())
-            ->method("get")
-            ->will($this->returnValue("testValue"));
-
-        $mock->expects($this->atLeastOnce())
-            ->method("delete")
-            ->will($this->returnValue(true));
-
-        $this->assertEquals($mock->getId(), 'rrcpqo7tkjh14d2vmf0car64k7', 'Session ID is not equal');
-
-        $this->assertEquals($mock->get('testKey'), 'testValue', 'The value is not equal to the one which was set');
-
-        $this->assertEquals($mock->delete('testKey'), true);
-
-        $this->assertEquals($mock->set('testKey', 'testValue'), null, 'The value is not equal to the one which was set');
-
-        $this->assertEquals($mock->destroy(), null, 'Session destroy did not return true');
-    }
-}
\ No newline at end of file
diff --git a/tests/modules/Cart/ServiceTest.php b/tests/modules/Cart/ServiceTest.php
index 7db4d7ed3..23fefe7bb 100644
--- a/tests/modules/Cart/ServiceTest.php
+++ b/tests/modules/Cart/ServiceTest.php
@@ -49,11 +49,11 @@ class ServiceTest extends \BBTestCase
         $dbMock = $this->getMockBuilder('Box_Database')->getMock();
         $dbMock->expects($this->atLeastOnce())
             ->method('findOne')
             ->will($this->returnValue($model));
 
-        $sessionMock = $this->getMockBuilder("\Box_Session")
+        $sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
             ->disableOriginalConstructor()
             ->getMock();
         $sessionMock->expects($this->atLeastOnce())
             ->method("getId")
             ->will($this->returnValue($session_id));
@@ -109,11 +109,11 @@ class ServiceTest extends \BBTestCase
             ->will($this->returnValue($modelCart));
         $dbMock->expects($this->atLeastOnce())
             ->method('store')
             ->will($this->returnValue(rand(1, 100)));
 
-        $sessionMock = $this->getMockBuilder("\Box_Session")
+        $sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
             ->disableOriginalConstructor()
             ->getMock();
         $sessionMock->expects($this->atLeastOnce())
             ->method("getId")
             ->will($this->returnValue($session_id));
diff --git a/tests/modules/Client/Api/AdminTest.php b/tests/modules/Client/Api/AdminTest.php
index 9fca4f9f8..3975ede72 100644
--- a/tests/modules/Client/Api/AdminTest.php
+++ b/tests/modules/Client/Api/AdminTest.php
@@ -107,11 +107,11 @@ class AdminTest extends \BBTestCase
         );
         $serviceMock  = $this->getMockBuilder('\Box\Mod\Client\Service')->getMock();
         $serviceMock->expects($this->atLeastOnce())->
         method('toSessionArray')->will($this->returnValue($sessionArray));
 
-        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
+        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
         $sessionMock->expects($this->atLeastOnce())->
         method('set');
 
         $di                = new \Pimple\Container();
         $di['db']          = $dbMock;
diff --git a/tests/modules/Client/Api/GuestTest.php b/tests/modules/Client/Api/GuestTest.php
index 50a0e339a..e9705a435 100644
--- a/tests/modules/Client/Api/GuestTest.php
+++ b/tests/modules/Client/Api/GuestTest.php
@@ -186,11 +186,11 @@ class GuestTest extends \BBTestCase {
 
         $eventMock = $this->getMockBuilder('\Box_EventManager')->getMock();
         $eventMock->expects($this->atLeastOnce())->
             method('fire');
 
-        $sessionMock = $this->getMockBuilder('\Box_Session')
+        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
             ->disableOriginalConstructor()
             ->getMock();
 
         $sessionMock->expects($this->atLeastOnce())
             ->method("set");
diff --git a/tests/modules/Cron/ServiceTest.php b/tests/modules/Cron/ServiceTest.php
index afc95e243..d1cd5f27f 100644
--- a/tests/modules/Cron/ServiceTest.php
+++ b/tests/modules/Cron/ServiceTest.php
@@ -59,17 +59,23 @@ class ServiceTest extends \BBTestCase {
             ->method('setParamValue');
 
         $eventsMock = $this->getMockBuilder('\Box_EventManager')->getMock();
         $eventsMock->expects($this->atLeastOnce())
             ->method('fire');
+        
+        $dbMock = $this->getMockBuilder('Box_Database')->getMock();
+        $dbMock->expects($this->atLeastOnce())
+            ->method('findAll')
+            ->will($this->returnValue([]));
 
         $di = new \Pimple\Container();
         $di['logger'] = new \Box_Log();
         $di['events_manager'] = $eventsMock;
         $di['api_system'] = $apiSystem;
         $di['mod_service'] = $di->protect(function() use($systemServiceMock) {return $systemServiceMock;});
         $serviceMock->setDi($di);
+        $di['db'] = $dbMock;
 
         $result = $serviceMock->runCrons();
         $this->assertTrue($result);
     }
 
diff --git a/tests/modules/Profile/Api/AdminTest.php b/tests/modules/Profile/Api/AdminTest.php
index ba3c456c8..b772631bd 100644
--- a/tests/modules/Profile/Api/AdminTest.php
+++ b/tests/modules/Profile/Api/AdminTest.php
@@ -39,11 +39,11 @@ class AdminTest extends \BBTestCase
         $this->assertEquals($expected, $result);
     }
 
     public function testLogout()
     {
-        $sessionMock = $this->getMockBuilder('\Box_Session')
+        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
             ->disableOriginalConstructor()
             ->getMock();
 
         $di = new \Pimple\Container();
         $di['session'] = $sessionMock;
diff --git a/tests/modules/Profile/ServiceTest.php b/tests/modules/Profile/ServiceTest.php
index 094e2a41b..e1f21a9df 100644
--- a/tests/modules/Profile/ServiceTest.php
+++ b/tests/modules/Profile/ServiceTest.php
@@ -376,16 +376,16 @@ class ServiceTest extends \BBTestCase
         $this->assertTrue($result);
     }
 
     public function testLogoutClient()
     {
-        $sessionMock = $this->getMockBuilder("\Box_Session")
+        $sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
             ->disableOriginalConstructor()
             ->getMock();
 
         $sessionMock->expects($this->atLeastOnce())
-            ->method("delete");
+            ->method("destroy");
 
         $di            = new \Pimple\Container();
         $di['logger']  = new \Box_Log();
         $di['session'] = $sessionMock;
 
diff --git a/tests/modules/Staff/ServiceTest.php b/tests/modules/Staff/ServiceTest.php
index c78316fb3..8de72682b 100644
--- a/tests/modules/Staff/ServiceTest.php
+++ b/tests/modules/Staff/ServiceTest.php
@@ -39,11 +39,11 @@ class ServiceTest extends \BBTestCase
             ->getMock();
         $dbMock->expects($this->atLeastOnce())
             ->method('findOne')
             ->will($this->returnValue($admin));
 
-        $sessionMock = $this->getMockBuilder('\Box_Session')
+        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
             ->disableOriginalConstructor()
             ->getMock();
         $sessionMock->expects($this->atLeastOnce())
             ->method('set')
             ->will($this->returnValue(null));
@@ -1172,43 +1172,24 @@ class ServiceTest extends \BBTestCase
             ->will($this->returnValue($newId));
 
         $logMock = $this->getMockBuilder('\Box_Log')->getMock();
 
         $systemService = $this->getMockBuilder('\Box\Mod\System\Service')->getMock();
-        $systemService->expects($this->atLeastOnce())
-            ->method('getParamValue');
-
-        $emailServiceMock = $this->getMockBuilder('\Box\Mod\Email\Service')->getMock();
-        $emailServiceMock->expects($this->atLeastOnce())
-            ->method('sendMail');
-
-        $urlMock = $this->getMockBuilder('\Box_Url')->getMock();
-        $urlMock->expects($this->atLeastOnce())
-            ->method('link')
-            ->willReturn('');
-        $urlMock->expects($this->atLeastOnce())
-            ->method('adminLink')
-            ->willReturn('');
-
 
         $passwordMock = $this->getMockBuilder('\Box_Password')->getMock();
         $passwordMock->expects($this->atLeastOnce())
             ->method('hashIt')
             ->with($data['password']);
 
         $di                = new \Pimple\Container();
         $di['logger']      = $logMock;
         $di['db']          = $dbMock;
-        $di['mod_service'] = $di->protect(function ($serviceName) use ($systemService, $emailServiceMock) {
+        $di['mod_service'] = $di->protect(function ($serviceName) use ($systemService) {
             if ('system' == $serviceName) {
                 return $systemService;
             }
-            if ('Email' == $serviceName) {
-                return $emailServiceMock;
-            }
         });
-        $di['url']         = $urlMock;
         $di['password']    = $passwordMock;
 
         $service = new \Box\Mod\Staff\Service();
         $service->setDi($di);
 
diff --git a/tests/modules/System/ServiceTest.php b/tests/modules/System/ServiceTest.php
index b0a52d6f0..fc2a3b030 100644
--- a/tests/modules/System/ServiceTest.php
+++ b/tests/modules/System/ServiceTest.php
@@ -332,11 +332,11 @@ class ServiceTest extends \BBTestCase {
 
     public function testgetPendingMessages()
     {
         $di = new \Pimple\Container();
 
-        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
+        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
         $sessionMock->expects($this->atLeastOnce())
             ->method('get')
             ->with('pending_messages')
             ->willReturn(array());
 
@@ -349,11 +349,11 @@ class ServiceTest extends \BBTestCase {
 
     public function testgetPendingMessages_GetReturnsNotArray()
     {
         $di = new \Pimple\Container();
 
-        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
+        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
         $sessionMock->expects($this->atLeastOnce())
             ->method('get')
             ->with('pending_messages')
             ->willReturn(null);
 
@@ -373,11 +373,11 @@ class ServiceTest extends \BBTestCase {
             ->method('getPendingMessages')
             ->willReturn(array());
 
         $di = new \Pimple\Container();
 
-        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
+        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
         $sessionMock->expects($this->atLeastOnce())
             ->method('set')
             ->with('pending_messages');
 
         $di['session'] = $sessionMock;
@@ -391,11 +391,11 @@ class ServiceTest extends \BBTestCase {
 
     public function testclearPendingMessages()
     {
         $di = new \Pimple\Container();
 
-        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
+        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
         $sessionMock->expects($this->atLeastOnce())
             ->method('delete')
             ->with('pending_messages');
         $di['session'] = $sessionMock;
         $this->service->setDi($di);

Vulnerable code: | 0.4.x   | :white_check_mark: |
        "BOXCLR",
        "subkeys"
 * @return \Box_Session
    $mode = (isset($di['config']['security']['mode'])) ? $di['config']['security']['mode'] : 'strict';
    $lifespan = (isset($di['config']['security']['cookie_lifespan'])) ? $di['config']['security']['cookie_lifespan'] : 7200;
    $secure = (isset($di['config']['security']['force_https'])) ? $di['config']['security']['force_https'] : true;
    return new Box_Session($handler, $mode, $lifespan, $secure);
	(1,'last_patch','33',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
<?php
/**
 * Copyright 2022-2023 FOSSBilling
 * Copyright 2011-2021 BoxBilling, Inc.
 * SPDX-License-Identifier: Apache-2.0
 *
 * @copyright FOSSBilling (https://www.fossbilling.org)
 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
 */

class Box_Session
{
    public function setRandomId()
    {
        $id = random_bytes(64);
        session_id($id);
    }


    public function __construct($handler, $securityMode = 'regular', $cookieLifespan = 7200, $secure = true)
    {
        if (!headers_sent()) {
            session_set_save_handler(
                array($handler, 'open'),
                array($handler, 'close'),
                array($handler, 'read'),
                array($handler, 'write'),
                array($handler, 'destroy'),
                array($handler, 'gc')
            );
        }
        if (php_sapi_name() !== 'cli') {
            $currentCookieParams = session_get_cookie_params();
            $currentCookieParams["httponly"] = true;
            $currentCookieParams["lifetime"] = $cookieLifespan;
            $currentCookieParams["secure"] = $secure;

            if ($securityMode == 'strict') {
                session_set_cookie_params([
                    'lifetime' => $currentCookieParams["lifetime"],
                    'path' => $currentCookieParams["path"],
                    'domain' => $currentCookieParams["domain"],
                    'secure' => $currentCookieParams["secure"],
                    'httponly' => $currentCookieParams["httponly"],
                    'samesite' => 'Strict'
                ]);
                // TODO: Adjust the DB to support 64 character long session IDs
                // Currently adjusting it causing issues within this file: https://github.com/FOSSBilling/FOSSBilling/blob/main/src/library/PdoSessionHandler.php
                //$this->setRandomId();
            } else {
                session_set_cookie_params(
                    $currentCookieParams["lifetime"],
                    $currentCookieParams["path"],
                    $currentCookieParams["domain"],
                    $currentCookieParams["secure"],
                    $currentCookieParams["httponly"]
                );
            }

            session_start();
        }
    }

    public function getId()
    {
        return session_id();
    }

    public function delete($key)
    {
        unset($_SESSION[$key]);
    }

    public function get($key)
    {
        return isset($_SESSION[$key]) ? $_SESSION[$key] : null;
    }

    public function set($key, $value)
    {
        $_SESSION[$key] = $value;
    }

    public function destroy()
    {
        session_destroy();
    }
}
            }
        if (isset($data['remember'])) {
            $email = $data['email'];
            $cookie_time = (3600 * 24 * 30); // 30 days
            setcookie('BOXCLR', 'e=' . base64_encode($email) . '&p=' . base64_encode($client->pass), time() + $cookie_time, '/');
        }

        $this->di['session']->delete('admin');
        if ($_COOKIE) { // testing env fix
            setcookie('BOXCLR', '', time() - 3600, '/');
        }
        $this->di['session']->delete('client');
        $this->di['session']->delete('client_id');
        $this->_sendMail($admin, $data['password']);

    protected function _sendMail($admin, $admin_pass)
    {
        $admin_name = $admin->name;
        $admin_email = $admin->email;

        $client_url = $this->di['url']->link('/');
        $admin_url = $this->di['url']->adminLink('/');

        $content = "Hello, $admin_name. " . PHP_EOL;
        $content .= 'You have successfully installed FOSSBilling at ' . BB_URL . PHP_EOL;
        $content .= 'Access the client area at: ' . $client_url . PHP_EOL;
        $content .= 'Access the admin area at: ' . $admin_url . ' with login details:' . PHP_EOL;
        $content .= 'Email: ' . $admin_email . PHP_EOL;
        $content .= 'Password: ' . $admin_pass . PHP_EOL . PHP_EOL;

        $content .= 'Read the FOSSBilling documentation to get started https://fossbilling.org/docs' . PHP_EOL;
        $content .= 'Thank you for using FOSSBilling.' . PHP_EOL;

        $subject = sprintf('FOSSBilling is ready at "%s"', BB_URL);

        $systemService = $this->di['mod_service']('system');
        $from = $systemService->getParamValue('company_email');
        $emailService = $this->di['mod_service']('Email');
        $emailService->sendMail($admin_email, $from, $subject, $content);
    }

        $this->assertInstanceOf('\Box_Session', $di['session']);
<?php
/**
 * @group Core
 */
class Box_SessionTest extends PHPUnit\Framework\TestCase
{
    public function testSession()
    {
        $mock = $this->getMockBuilder("\Box_Session")
            ->disableOriginalConstructor()
            ->getMock();

        $mock->expects($this->atLeastOnce())
            ->method("getId")
            ->will($this->returnValue("rrcpqo7tkjh14d2vmf0car64k7"));

        $mock->expects($this->atLeastOnce())
            ->method("get")
            ->will($this->returnValue("testValue"));

        $mock->expects($this->atLeastOnce())
            ->method("delete")
            ->will($this->returnValue(true));

        $this->assertEquals($mock->getId(), 'rrcpqo7tkjh14d2vmf0car64k7', 'Session ID is not equal');

        $this->assertEquals($mock->get('testKey'), 'testValue', 'The value is not equal to the one which was set');

        $this->assertEquals($mock->delete('testKey'), true);

        $this->assertEquals($mock->set('testKey', 'testValue'), null, 'The value is not equal to the one which was set');

        $this->assertEquals($mock->destroy(), null, 'Session destroy did not return true');
    }
}
        $sessionMock = $this->getMockBuilder("\Box_Session")
        $sessionMock = $this->getMockBuilder("\Box_Session")
        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
        $sessionMock = $this->getMockBuilder('\Box_Session')
        $sessionMock = $this->getMockBuilder('\Box_Session')
        $sessionMock = $this->getMockBuilder("\Box_Session")
            ->method("delete");
        $sessionMock = $this->getMockBuilder('\Box_Session')
        $systemService->expects($this->atLeastOnce())
            ->method('getParamValue');

        $emailServiceMock = $this->getMockBuilder('\Box\Mod\Email\Service')->getMock();
        $emailServiceMock->expects($this->atLeastOnce())
            ->method('sendMail');

        $urlMock = $this->getMockBuilder('\Box_Url')->getMock();
        $urlMock->expects($this->atLeastOnce())
            ->method('link')
            ->willReturn('');
        $urlMock->expects($this->atLeastOnce())
            ->method('adminLink')
            ->willReturn('');

        $di['mod_service'] = $di->protect(function ($serviceName) use ($systemService, $emailServiceMock) {
            if ('Email' == $serviceName) {
                return $emailServiceMock;
            }
        $di['url']         = $urlMock;
        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
        $sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
Fixed code: [![huntr](https://cdn.huntr.dev/huntr_security_badge_mono.svg)](https://huntr.dev/repos/fossbilling/fossbilling/)

| 0.5.x   | :white_check_mark: |
| 0.4.x   | :x: |
        "subkeys",
        "httponly",
        "samesite"
 * @return \FOSSBilling\Session
    $mode = $di['config']['security']['mode'] ?? 'strict';
    $lifespan = $di['config']['security']['cookie_lifespan'] ?? 7200;
    $secure = $di['config']['security']['force_https'] ?? true;

    $session = new \FOSSBilling\Session($handler, $mode, $lifespan, $secure);
    $session->setDi($di);
    $session->setupSession();

    return $session;
	(1,'last_patch','34',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
  `fingerprint` text,
<?php
declare(strict_types=1);
/**
 * Copyright 2022-2023 FOSSBilling
 * Copyright 2011-2021 BoxBilling, Inc.
 * SPDX-License-Identifier: Apache-2.0
 *
 * @copyright FOSSBilling (https://www.fossbilling.org)
 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
 */

namespace FOSSBilling;

class Fingerprint
{
    private array $fingerprintProperties;

    public function __construct()
    {
        $agentDetails = $this->extractAgentInfo();

        /**
         * Sets up the fingerprint info for the existing request.
         * 'weight' is used to weigh specific parameters.
         *      Example: The agent string has a weight of 2, one failure from it equal as 2 failures of other properties.
         *      By doing this, we can prevent minor changes such as a browser update from requiring the user to re-authenticate.
         *      But it does mean that if the that property and one-or-two other ones fail, the user will need to re-authenticate.
         */
        $this->fingerprintProperties = [
            'agentString' => [
                'source' => $agentDetails['userAgent'],
                'weight' => 2,
            ],
            'browser' => [
                'source' => $agentDetails['browser'],
                'weight' => 100, // Always fail if this doesn't match.
            ],
            'browserVersion' => [
                'source' => $agentDetails['browserVersion'],
                'weight' => 1,
            ],
            'os' => [
                'source' => $agentDetails['os'],
                'weight' => 100, // Always fail if this doesn't match.
            ],
            'ip' => [
                'source' => $_SERVER['REMOTE_ADDR'] ?? '',
                'weight' => 3,
            ],
            'referrer' => [
                'source' => $_SERVER['HTTP_REFERER'] ?? '',
                'weight' => 1,
            ],
            'forwardedFor' => [
                'source' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '',
                'weight' => 3,
            ],
            'language' => [
                'source' => $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '',
                'weight' => 2,
            ],
            'encoding' => [
                'source' => $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '',
                'weight' => 1,
            ],
            'upgradeRequests' => [
                'source' => $_SERVER['HTTP_UPGRADE_INSECURE_REQUESTS'] ?? '',
                'weight' => 1,
            ],
        ];
    }

    /**
     * Generates a fingerprint for the device that made the request to the server
     */
    public function fingerprint(): array
    {
        $fingerprint = [];

        foreach ($this->fingerprintProperties as $name => $properties) {
            if (!empty($properties['source'])) {
                $fingerprint[$name] = hash('md5', $properties['source']);
            }
        }

        return $fingerprint;
    }

    /**
     * Compares a provided fingerprint against one generated for the device that made the request to the server.
     * This function creates a baseline "score" with the total of properties in the fingerprint. The final score must be at least half of the baseline.
     *      - Each property can define a weight. For example, if the IP address doesn't match and the weight is set to 3, 3 will be selected from the total.
     *          - This means with a total of 9 properties, the IP address being wrong would effectively be weighted as 3 properties and only two more differing properties will make it fail the check.
     *      - If any property is found in one of the fingerprints and not the other, the baseline is incremented and the final score is decreased by it's weight.
     */
    public function checkFingerprint(array $fingerprint): bool
    {
        $itemCount = 0;
        $scoreSubtract = 0;

        foreach ($this->fingerprintProperties as $name => $properties) {
            $exitsInFingerprint = array_key_exists($name, $fingerprint);
            $exitsInCurrentFingerprint = !empty($properties['source']);

            if ((!$exitsInFingerprint && $exitsInCurrentFingerprint) || ($exitsInFingerprint && !$exitsInCurrentFingerprint)) {
                //The property exists in one fingerprint and not the other, so we increment the total count and deduct from the score.
                $itemCount++;
                $scoreSubtract += $properties['weight'];
            } elseif (!$exitsInFingerprint && !$exitsInCurrentFingerprint) {
                // Do nothing in this case, as the property isn't in either fingerprint.
            } else {
                $itemCount++;
                $hashedData = hash('md5', $properties['source']);

                if ($fingerprint[$name] !== $hashedData) {
                    $scoreSubtract += $properties['weight'];
                }
            }
        }

        // Remove the total score from the total number of items. The final score must be at least half the number of properties in order for the fingerprint to be considered valid.
        $finalScore = $itemCount - $scoreSubtract;
        return $finalScore >= ($itemCount / 2);
    }

    private function extractAgentInfo(): array
    {
        $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';

        // Extract the browser name
        if (preg_match('/(?:Chrome|CriOS)\/([0-9\.]+)/', $userAgent, $matches)) {
            $browser = 'Chrome';
            $version = $matches[1];
        } elseif (preg_match('/Firefox\/([0-9\.]+)/', $userAgent, $matches)) {
            $browser = 'Firefox';
            $version = $matches[1];
        } elseif (preg_match('/Safari\/([0-9\.]+)/', $userAgent, $matches)) {
            $browser = 'Safari';
            $version = $matches[1];
        } else {
            $browser = 'Unknown';
            $version = 'Unknown';
        }

        // Extract the operating system
        if (preg_match('/Windows NT ([0-9\.]+)/', $userAgent, $matches)) {
            $os = 'Windows NT ' . $matches[1];
        } elseif (preg_match('/Mac OS X ([0-9_]+)/', $userAgent, $matches)) {
            $os = 'Mac OS X';
        } elseif (preg_match('/Linux/', $userAgent)) {
            $os = 'Linux';
        } else {
            $os = 'Unknown';
        }

        return [
            'browser' => $browser,
            'browserVersion' => $version,
            'os' => $os,
            'userAgent' => $userAgent,
        ];
    }
}
<?php
declare(strict_types=1);
/**
 * Copyright 2022-2023 FOSSBilling
 * Copyright 2011-2021 BoxBilling, Inc.
 * SPDX-License-Identifier: Apache-2.0
 *
 * @copyright FOSSBilling (https://www.fossbilling.org)
 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
 */

namespace FOSSBilling;

class Session implements \FOSSBilling\InjectionAwareInterface
{
    private ?\Pimple\Container $di;
    private \PdoSessionHandler $handler;
    private string $securityMode;
    private int $cookieLifespan;
    private bool $secure;

    public function setDi(\Pimple\Container|null $di): void
    {
        $this->di = $di;
    }

    public function getDi(): ?\Pimple\Container
    {
        return $this->di;
    }

    public function __construct(\PdoSessionHandler $handler, string $securityMode = 'regular', int $cookieLifespan = 7200, bool $secure = true)
    {
        $this->handler = $handler;
        $this->securityMode = $securityMode;
        $this->cookieLifespan = $cookieLifespan;
        $this->secure = $secure;
    }

    public function setupSession()
    {
        if (php_sapi_name() === 'cli') {
            return;
        }

        $this->canUseSession();

        if (!headers_sent()) {
            session_set_save_handler(
                [$this->handler, 'open'],
                [$this->handler, 'close'],
                [$this->handler, 'read'],
                [$this->handler, 'write'],
                [$this->handler, 'destroy'],
                [$this->handler, 'gc']
            );
        }

        $currentCookieParams = session_get_cookie_params();
        $currentCookieParams["httponly"] = true;
        $currentCookieParams["lifetime"] = $this->cookieLifespan;
        $currentCookieParams["secure"] = $this->secure;

        $cookieParams = [
            'lifetime' => $currentCookieParams["lifetime"],
            'path' => $currentCookieParams["path"],
            'domain' => $currentCookieParams["domain"],
            'secure' => $currentCookieParams["secure"],
            'httponly' => $currentCookieParams["httponly"]
        ];

        if ($this->securityMode == 'strict') {
            $cookieParams['samesite'] = 'Strict';
        }

        session_set_cookie_params($cookieParams);
        session_start();

        $this->updateFingerprint();
    }

    public function getId(): string
    {
        return session_id();
    }

    public function delete(string $key): void
    {
        unset($_SESSION[$key]);
    }

    public function get(string $key): mixed
    {
        return $_SESSION[$key] ?? null;
    }

    public function set(string $key, mixed $value): void
    {
        $_SESSION[$key] = $value;
    }

    public function destroy(string $type = ''): bool
    {
        switch ($type) {
            case 'admin':
                $this->delete('admin');
                break;
            case 'client':
                $this->delete('client');
                $this->delete('client_id');
                break;
        }

        return session_destroy();
    }

    /**
     * Checks both the fingerprint and age of the current session to see if it can be used.
     * If the session can't be used, it's destroyed from the database, forcing a new one to be created.
     */
    private function canUseSession():void
    {
        if (empty($_COOKIE['PHPSESSID'])) {
            return;
        }

        $sessionID = $_COOKIE['PHPSESSID'];
        $maxAge = time() - $this->di['config']['security']['cookie_lifespan'];

        $fingerprint = new \FOSSBilling\Fingerprint;
        $session = $this->di['db']->findOne('session', 'id = :id', [':id' => $sessionID]);

        if (empty($session->fingerprint)) {
            return;
        }

        if (!$fingerprint->checkFingerprint(json_decode($session->fingerprint, true)) || $session->modified_at <= $maxAge) {
            $this->di['db']->trash($session);
            unset($_COOKIE['PHPSESSID']);
        }
    }

    /**
     * Depending on the specifics, this will either set or update the fingerprint associated with the current session.
     */
    private function updateFingerprint():void
    {
        $sessionID = $_COOKIE['PHPSESSID'] ?? session_id();
        $session = $this->di['db']->findOne('session', 'id = :id', [':id' => $sessionID]);

        $fingerprint = new \FOSSBilling\Fingerprint;
        $session->fingerprint = json_encode($fingerprint->fingerprint());
        $this->di['db']->store($session);
    }
}
            },
            34 => function() {
                // Adds the new "fingerprint" to the session table, to allow us to fingerprint devices and help prevent against attacks such as session hijacking.
                $q = "ALTER TABLE session ADD fingerprint TEXT;";
                $this->executeSql($q);
            },
        session_regenerate_id();
        $this->clearOldSessions();
        $this->di['logger']->setChannel('cron')->info('Cleared outdated sessions from the database');


    private function clearOldSessions()
    {
        $sessions = $this->di['db']->findAll('session');
        foreach ($sessions as $session){
            $maxAge = time() - $this->di['config']['security']['cookie_lifespan'];
            if($session->modified_at <= $maxAge){
                $this->di['db']->trash($session);
            }
        }
    }
        $this->di['session']->destroy('admin');
        $this->di['session']->destroy('client');
        session_regenerate_id();
        $this->assertInstanceOf('\FOSSBilling\Session', $di['session']);
        $sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
        $sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
        
        $dbMock = $this->getMockBuilder('Box_Database')->getMock();
        $dbMock->expects($this->atLeastOnce())
            ->method('findAll')
            ->will($this->returnValue([]));
        $di['db'] = $dbMock;
        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
        $sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
            ->method("destroy");
        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
        $di['mod_service'] = $di->protect(function ($serviceName) use ($systemService) {
        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
        $sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();