CVE-2023-3394 (PHP, published 2023-06-23T19:15Z)
Description: value: Session Fixation in GitHub repository fossbilling/fossbilling prior to 0.5.1.
Severity: nan (CVSS v3 base score: 5.4)
Weakness: CWE-384 \u2014 Session Fixation
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.
Repository: https://github.com/fossbilling/fossbilling
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 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

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