CVE-2024-29023: Insufficient Information (NVD-CWE-noinfo)
value: Xibo is an Open Source Digital Signage platform with a web content management system and Windows display player software. Session tokens are exposed in the return of session search API call on the sessions page. Subsequently they can be exfiltrated and used to hijack a session. Users must be granted access to the session page, or be a super admin. Users should upgrade to version 3.3.10 or 4.0.9 which fix this issue. Customers who host their CMS with the Xibo Signage service have already received an upgrade or patch to resolve this issue regardless of the CMS version that they are running. Patches are available for earlier versions of Xibo CMS that are out of security support: 2.3 patch ebeccd000b51f00b9a25f56a2f252d6812ebf850.diff. 1.8 patch a81044e6ccdd92cc967e34c125bd8162432e51bc.diff. There are no known workarounds for this vulnerability.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-04-12T21:15Z
Repository: https://github.com/xibosignage/xibo-cms
Language: PHP
Files changed: lib/Controller/Sessions.php, lib/Factory/SessionFactory.php, lib/Helper/Environment.php, lib/Helper/Session.php, lib/Xmds/Soap.php, views/sessions-form-confirm-logout.twig
Commit date: 2024-03-18 12:40:49 +0000
Commit message: Improvements to filtering IP and sessions (#2433)

* Sessions: Add sanitization in getIp() and disable output of session ID in session grid. xibosignage/xibo#3375
* Bump to 4.0.9
Diff: diff --git a/lib/Controller/Sessions.php b/lib/Controller/Sessions.php
index 4a3418fb1..5e57c31af 100644
--- a/lib/Controller/Sessions.php
+++ b/lib/Controller/Sessions.php
@@ -94,19 +94,24 @@ class Sessions extends Base
                 Carbon::createFromTimeString($row->lastAccessed)?->format(DateFormatHelper::getSystemFormat());
 
             if (!$this->isApi($request) && $this->getUser()->isSuperAdmin()) {
                 $row->includeProperty('buttons');
 
+                // No buttons on expired sessions
+                if ($row->isExpired == 1) {
+                    continue;
+                }
+
                 // logout, current user/session
                 if ($row->userId === $this->getUser()->userId && session_id() === $row->sessionId) {
                     $url = $this->urlFor($request, 'logout');
                 } else {
                     // logout, different user/session
                     $url = $this->urlFor(
                         $request,
                         'sessions.confirm.logout.form',
-                        ['id' => $row->sessionId]
+                        ['id' => $row->userId]
                     );
                 }
 
                 $row->buttons[] = [
                     'id' => 'sessions_button_logout',
@@ -125,25 +130,25 @@ class Sessions extends Base
 
     /**
      * Confirm Logout Form
      * @param Request $request
      * @param Response $response
-     * @param $id
+     * @param int $id The UserID
      * @return \Psr\Http\Message\ResponseInterface|Response
      * @throws AccessDeniedException
      * @throws \Xibo\Support\Exception\ControllerNotImplemented
      * @throws \Xibo\Support\Exception\GeneralException
      */
-    function confirmLogoutForm(Request $request, Response $response, $id)
+    public function confirmLogoutForm(Request $request, Response $response, $id)
     {
         if ($this->getUser()->userTypeId != 1) {
             throw new AccessDeniedException();
         }
 
         $this->getState()->template = 'sessions-form-confirm-logout';
         $this->getState()->setData([
-            'sessionId' => $id,
+            'userId' => $id,
         ]);
 
         return $this->render($request, $response);
     }
 
@@ -156,29 +161,18 @@ class Sessions extends Base
      * @throws AccessDeniedException
      * @throws \Xibo\Support\Exception\ControllerNotImplemented
      * @throws \Xibo\Support\Exception\GeneralException
      * @throws \Xibo\Support\Exception\NotFoundException
      */
-    function logout(Request $request, Response $response, $id)
+    public function logout(Request $request, Response $response, $id)
     {
         if ($this->getUser()->userTypeId != 1) {
             throw new AccessDeniedException();
         }
 
-        $session = $this->sessionFactory->getById($id);
-
-        if ($session->userId != 0) {
-            $this->store->update(
-                'UPDATE `session` SET IsExpired = 1 WHERE userID = :userId ',
-                ['userId' => $session->userId]
-            );
-        } else {
-            $this->store->update(
-                'UPDATE `session` SET IsExpired = 1 WHERE session_id = :session_id ',
-                ['session_id' => $id]
-            );
-        }
+        // We log out all of this user's sessions.
+        $this->sessionFactory->expireByUserId($id);
 
         // Return
         $this->getState()->hydrate([
             'message' => __('User Logged Out.')
         ]);
diff --git a/lib/Factory/SessionFactory.php b/lib/Factory/SessionFactory.php
index 5626d8217..fe3023255 100644
--- a/lib/Factory/SessionFactory.php
+++ b/lib/Factory/SessionFactory.php
@@ -1,10 +1,10 @@
 <?php
 /*
- * Copyright (c) 2022 Xibo Signage Ltd
+ * Copyright (C) 2024 Xibo Signage Ltd
  *
- * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Xibo - Digital Signage - https://xibosignage.com
  *
  * This file is part of Xibo.
  *
  * Xibo is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as published by
@@ -24,11 +24,10 @@
 namespace Xibo\Factory;
 
 
 use Xibo\Entity\Session;
 use Xibo\Helper\DateFormatHelper;
-use Xibo\Support\Exception\NotFoundException;
 
 /**
  * Class SessionFactory
  * @package Xibo\Factory
  */
@@ -41,22 +40,18 @@ class SessionFactory extends BaseFactory
     {
         return new Session($this->getStore(), $this->getLog(), $this->getDispatcher());
     }
 
     /**
-     * @param $sessionId
-     * @return Session
-     * @throws NotFoundException
+     * @param int $userId
      */
-    public function getById($sessionId)
+    public function expireByUserId(int $userId): void
     {
-        $session = $this->query(null, ['sessionId' => $sessionId]);
-
-        if (count($session) <= 0)
-            throw new NotFoundException();
-
-        return $session[0];
+        $this->getStore()->update(
+            'UPDATE `session` SET IsExpired = 1 WHERE userID = :userId ',
+            ['userId' => $userId]
+        );
     }
 
     /**
      * @param int $userId
      * @return int loggedIn
@@ -141,11 +136,18 @@ class SessionFactory extends BaseFactory
         $sql = $select . $body . $order . $limit;
 
 
 
         foreach ($this->getStore()->select($sql, $params) as $row) {
-            $entries[] = $this->createEmpty()->hydrate($row, ['stringProperties' => ['sessionId']]);
+            $session = $this->createEmpty()->hydrate($row, [
+                'stringProperties' => ['sessionId'],
+                'intProperties' => ['isExpired'],
+            ]);
+            $session->userAgent = htmlspecialchars($session->userAgent);
+            $session->remoteAddress = filter_var($session->remoteAddress, FILTER_VALIDATE_IP);
+            $session->excludeProperty('sessionId');
+            $entries[] = $session;
         }
 
         // Paging
         if ($limit != '' && count($entries) > 0) {
             $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
diff --git a/lib/Helper/Environment.php b/lib/Helper/Environment.php
index 0599ebab0..694419c79 100644
--- a/lib/Helper/Environment.php
+++ b/lib/Helper/Environment.php
@@ -28,11 +28,11 @@ use Phinx\Wrapper\TextWrapper;
  * Class Environment
  * @package Xibo\Helper
  */
 class Environment
 {
-    public static $WEBSITE_VERSION_NAME = '4.0.8';
+    public static $WEBSITE_VERSION_NAME = '4.0.9';
     public static $XMDS_VERSION = '7';
     public static $XLF_VERSION = 4;
     public static $VERSION_REQUIRED = '8.1.0';
     public static $VERSION_UNSUPPORTED = '9.0';
     public static $PLAYER_SUPPORT = 300;
diff --git a/lib/Helper/Session.php b/lib/Helper/Session.php
index f68ed739c..c38485df1 100644
--- a/lib/Helper/Session.php
+++ b/lib/Helper/Session.php
@@ -1,8 +1,8 @@
 <?php
 /*
- * Copyright (C) 2023 Xibo Signage Ltd
+ * Copyright (C) 2024 Xibo Signage Ltd
  *
  * Xibo - Digital Signage - https://xibosignage.com
  *
  * This file is part of Xibo.
  *
@@ -170,11 +170,11 @@ class Session implements \SessionHandlerInterface
         //$this->log->debug('Session read');
 
         $data = '';
         $this->key = $key;
 
-        $userAgent = substr($_SERVER['HTTP_USER_AGENT'], 0, 253);
+        $userAgent = substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253);
 
         try {
             $dbh = $this->getDb();
 
             // Start a transaction
@@ -420,11 +420,11 @@ class Session implements \SessionHandlerInterface
             'session_data' => $data,
             'session_expiration' => $expiry,
             'lastAccessed' => Carbon::createFromTimestamp($lastAccessed)->format(DateFormatHelper::getSystemFormat()),
             'userId' => $this->userId,
             'expired' => ($this->expired) ? 1 : 0,
-            'useragent' => substr($_SERVER['HTTP_USER_AGENT'], 0, 253),
+            'useragent' => substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253),
             'remoteaddr' => $this->getIp()
         ];
 
         $this->getDb()->update($sql, $params);
     }
@@ -469,11 +469,11 @@ class Session implements \SessionHandlerInterface
     private function getIp()
     {
         $clientIp = '';
         $keys = array('X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR', 'CLIENT_IP', 'REMOTE_ADDR');
         foreach ($keys as $key) {
-            if (isset($_SERVER[$key])) {
+            if (isset($_SERVER[$key]) && filter_var($_SERVER[$key], FILTER_VALIDATE_IP) !== false) {
                 $clientIp = $_SERVER[$key];
                 break;
             }
         }
         return $clientIp;
diff --git a/lib/Xmds/Soap.php b/lib/Xmds/Soap.php
index 582906967..c33687bce 100644
--- a/lib/Xmds/Soap.php
+++ b/lib/Xmds/Soap.php
@@ -2638,11 +2638,11 @@ class Soap
     {
         $clientIp = '';
 
         $keys = array('X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR', 'CLIENT_IP', 'REMOTE_ADDR');
         foreach ($keys as $key) {
-            if (isset($_SERVER[$key])) {
+            if (isset($_SERVER[$key]) && filter_var($_SERVER[$key], FILTER_VALIDATE_IP) !== false) {
                 $clientIp = $_SERVER[$key];
                 break;
             }
         }
 
diff --git a/views/sessions-form-confirm-logout.twig b/views/sessions-form-confirm-logout.twig
index 9eab98875..4aa376443 100644
--- a/views/sessions-form-confirm-logout.twig
+++ b/views/sessions-form-confirm-logout.twig
@@ -34,11 +34,11 @@
 {% endblock %}
 
 {% block formHtml %}
     <div class="row">
         <div class="col-md-12">
-            <form id="sessionsConfirmLogoutForm" class="XiboForm form-horizontal" method="delete" action="{{ url_for("sessions.confirm.logout", {id: sessionId}) }}">
+            <form id="sessionsConfirmLogoutForm" class="XiboForm form-horizontal" method="delete" action="{{ url_for("sessions.confirm.logout", {id: userId}) }}">
                 {% set message %}{% trans "Are you sure you want to logout this user?" %}{% endset %}
                 {{ forms.message(message) }}
             </form>
         </div>
     </div>

Vulnerable code:                         ['id' => $row->sessionId]
     * @param $id
    function confirmLogoutForm(Request $request, Response $response, $id)
            'sessionId' => $id,
    function logout(Request $request, Response $response, $id)
        $session = $this->sessionFactory->getById($id);

        if ($session->userId != 0) {
            $this->store->update(
                'UPDATE `session` SET IsExpired = 1 WHERE userID = :userId ',
                ['userId' => $session->userId]
            );
        } else {
            $this->store->update(
                'UPDATE `session` SET IsExpired = 1 WHERE session_id = :session_id ',
                ['session_id' => $id]
            );
        }
 * Copyright (c) 2022 Xibo Signage Ltd
 * Xibo - Digital Signage - http://www.xibo.org.uk
use Xibo\Support\Exception\NotFoundException;
     * @param $sessionId
     * @return Session
     * @throws NotFoundException
    public function getById($sessionId)
        $session = $this->query(null, ['sessionId' => $sessionId]);

        if (count($session) <= 0)
            throw new NotFoundException();

        return $session[0];
            $entries[] = $this->createEmpty()->hydrate($row, ['stringProperties' => ['sessionId']]);
    public static $WEBSITE_VERSION_NAME = '4.0.8';
 * Copyright (C) 2023 Xibo Signage Ltd
        $userAgent = substr($_SERVER['HTTP_USER_AGENT'], 0, 253);
            'useragent' => substr($_SERVER['HTTP_USER_AGENT'], 0, 253),
            if (isset($_SERVER[$key])) {
            if (isset($_SERVER[$key])) {
            <form id="sessionsConfirmLogoutForm" class="XiboForm form-horizontal" method="delete" action="{{ url_for("sessions.confirm.logout", {id: sessionId}) }}">
Fixed code:                 // No buttons on expired sessions
                if ($row->isExpired == 1) {
                    continue;
                }

                        ['id' => $row->userId]
     * @param int $id The UserID
    public function confirmLogoutForm(Request $request, Response $response, $id)
            'userId' => $id,
    public function logout(Request $request, Response $response, $id)
        // We log out all of this user's sessions.
        $this->sessionFactory->expireByUserId($id);
 * Copyright (C) 2024 Xibo Signage Ltd
 * Xibo - Digital Signage - https://xibosignage.com
     * @param int $userId
    public function expireByUserId(int $userId): void
        $this->getStore()->update(
            'UPDATE `session` SET IsExpired = 1 WHERE userID = :userId ',
            ['userId' => $userId]
        );
            $session = $this->createEmpty()->hydrate($row, [
                'stringProperties' => ['sessionId'],
                'intProperties' => ['isExpired'],
            ]);
            $session->userAgent = htmlspecialchars($session->userAgent);
            $session->remoteAddress = filter_var($session->remoteAddress, FILTER_VALIDATE_IP);
            $session->excludeProperty('sessionId');
            $entries[] = $session;
    public static $WEBSITE_VERSION_NAME = '4.0.9';
 * Copyright (C) 2024 Xibo Signage Ltd
        $userAgent = substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253);
            'useragent' => substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253),
            if (isset($_SERVER[$key]) && filter_var($_SERVER[$key], FILTER_VALIDATE_IP) !== false) {
            if (isset($_SERVER[$key]) && filter_var($_SERVER[$key], FILTER_VALIDATE_IP) !== false) {
            <form id="sessionsConfirmLogoutForm" class="XiboForm form-horizontal" method="delete" action="{{ url_for("sessions.confirm.logout", {id: userId}) }}">