CVE-2023-49804 (published 2023-12-11T23:15Z)

Uptime Kuma is an easy-to-use self-hosted monitoring tool. Prior to version 1.23.9, when a user changes their login password in Uptime Kuma, a previously logged-in user retains access without being logged out. This behavior persists consistently, even after system restarts or browser restarts. This vulnerability allows unauthorized access to user accounts, compromising the security of sensitive information. The same vulnerability was partially fixed in  CVE-2023-44400, but logging existing users out of their accounts was forgotten. To mitigate the risks associated with this vulnerability, the maintainers made the server emit a `refresh` event (clients handle this by reloading) and then disconnecting all clients except the one initiating the password change. It is recommended to update Uptime Kuma to version 1.23.9.

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

Weakness information:
cwe_id: CWE-384
cwe_name: Session Fixation
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.
extended_description: 
            Such a scenario is commonly observed when:
            
               A web application authenticates a user without first invalidating the existing session, thereby continuing to use the session already associated with the user.
               An attacker is able to force a known session identifier on a user so that, once the user authenticates, the attacker has access to the authenticated session.
               The application or container uses predictable session identifiers.
	    
	    In the generic exploit of session fixation vulnerabilities, an attacker creates a new session on a web application and records the associated session identifier. The attacker then causes the victim to associate, and possibly authenticate, against the server using that session identifier, giving the attacker access to the user's account through the active session.
         
         

Fix information:
repo_url: https://github.com/louislam/uptime-kuma
commit_details: repo_url: https://github.com/louislam/uptime-kuma
author: Louis Lam
author_date: 2023-12-10 20:40:40+08:00
msg: Merge pull request from GHSA-88j4-pcx8-q4q3

* WIP, still need to handle npm run reset-password

* Implement it for "npm run reset-password"

Bug fixes and change along with this commit
- Move `ssl`, `hostname`, `port` to ./server/config.js, so `reset-password` is able to read it
- Fix: FBSD is missing, no idea who dropped it.
- Fix: Frontend code should not require any backend code (./server/config.js), moved "badgeConstants" to the common util (./src/util.ts) and drop vite-common.js

* Minor
repository: repo_url: https://github.com/louislam/uptime-kuma
repo_name: louislam/uptime-kuma
description: A fancy self-hosted monitoring tool
repo_language: JavaScript
owner: louislam
file_changes: filename: vite.config.js
change_type: ModificationType.MODIFY
diff: @@ -3,7 +3,6 @@ import vue from "@vitejs/plugin-vue";
 import { defineConfig } from "vite";
 import visualizer from "rollup-plugin-visualizer";
 import viteCompression from "vite-plugin-compression";
-import commonjs from "vite-plugin-commonjs";
 
 const postCssScss = require("postcss-scss");
 const postcssRTLCSS = require("postcss-rtlcss");
@@ -22,7 +21,6 @@ export default defineConfig({
         "CODESPACE_NAME": JSON.stringify(process.env.CODESPACE_NAME),
     },
     plugins: [
-        commonjs(),
         vue(),
         legacy({
             targets: [ "since 2015" ],

programming_language: JavaScript
filename: healthcheck.js
change_type: ModificationType.MODIFY
diff: @@ -6,7 +6,7 @@
  * ⚠️ Deprecated: Changed to healthcheck.go, it will be deleted in the future.
  * This script should be run after a period of time (180s), because the server may need some time to prepare.
  */
-const { FBSD } = require("../server/util-server");
+const FBSD = /^freebsd/.test(process.platform);
 
 process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
 

programming_language: TypeScript
filename: reset-password.js
change_type: ModificationType.MODIFY
diff: @@ -5,6 +5,8 @@ const { R } = require("redbean-node");
 const readline = require("readline");
 const { initJWTSecret } = require("../server/util-server");
 const User = require("../server/model/user");
+const { io } = require("socket.io-client");
+const { localWebSocketURL } = require("../server/config");
 const args = require("args-parser")(process.argv);
 const rl = readline.createInterface({
     input: process.stdin,
@@ -36,12 +38,16 @@ const main = async () => {
                     // Reset all sessions by reset jwt secret
                     await initJWTSecret();
 
+                    // Disconnect all other socket clients of the user
+                    await disconnectAllSocketClients(user.username, password);
+
                     break;
                 } else {
                     console.log("Passwords do not match, please try again.");
                 }
             }
             console.log("Password reset successfully.");
+
         }
     } catch (e) {
         console.error("Error: " + e.message);
@@ -66,6 +72,45 @@ function question(question) {
     });
 }
 
+function disconnectAllSocketClients(username, password) {
+    return new Promise((resolve) => {
+        console.log("Connecting to " + localWebSocketURL + " to disconnect all other socket clients");
+
+        // Disconnect all socket connections
+        const socket = io(localWebSocketURL, {
+            transports: [ "websocket" ],
+            reconnection: false,
+            timeout: 5000,
+        });
+        socket.on("connect", () => {
+            socket.emit("login", {
+                username,
+                password,
+            }, (res) => {
+                if (res.ok) {
+                    console.log("Logged in.");
+                    socket.emit("disconnectOtherSocketClients");
+                } else {
+                    console.warn("Login failed.");
+                    console.warn("Please restart the server to disconnect all sessions.");
+                }
+                socket.close();
+            });
+        });
+
+        socket.on("connect_error", function () {
+            // The localWebSocketURL is not guaranteed to be working for some complicated Uptime Kuma setup
+            // Ask the user to restart the server manually
+            console.warn("Failed to connect to " + localWebSocketURL);
+            console.warn("Please restart the server to disconnect all sessions manually.");
+            resolve();
+        });
+        socket.on("disconnect", () => {
+            resolve();
+        });
+    });
+}
+
 if (!process.env.TEST_BACKEND) {
     main();
 }

programming_language: TypeScript
filename: package.json
change_type: ModificationType.MODIFY
diff: @@ -192,7 +192,6 @@
         "typescript": "~4.4.4",
         "v-pagination-3": "~0.1.7",
         "vite": "~4.4.1",
-        "vite-plugin-commonjs": "^0.8.0",
         "vite-plugin-compression": "^0.5.1",
         "vue": "~3.3.4",
         "vue-chartjs": "~5.2.0",

programming_language: Shell
filename: config.js
change_type: ModificationType.MODIFY
diff: @@ -1,29 +1,42 @@
+const isFreeBSD = /^freebsd/.test(process.platform);
+
 // Interop with browser
 const args = (typeof process !== "undefined") ? require("args-parser")(process.argv) : {};
-const demoMode = args["demo"] || false;
 
-const badgeConstants = {
-    naColor: "#999",
-    defaultUpColor: "#66c20a",
-    defaultWarnColor: "#eed202",
-    defaultDownColor: "#c2290a",
-    defaultPendingColor: "#f8a306",
-    defaultMaintenanceColor: "#1747f5",
-    defaultPingColor: "blue",  // as defined by badge-maker / shields.io
-    defaultStyle: "flat",
-    defaultPingValueSuffix: "ms",
-    defaultPingLabelSuffix: "h",
-    defaultUptimeValueSuffix: "%",
-    defaultUptimeLabelSuffix: "h",
-    defaultCertExpValueSuffix: " days",
-    defaultCertExpLabelSuffix: "h",
-    // Values Come From Default Notification Times
-    defaultCertExpireWarnDays: "14",
-    defaultCertExpireDownDays: "7"
-};
+// If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise.
+// Dual-stack support for (::)
+// Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD
+let hostEnv = isFreeBSD ? null : process.env.HOST;
+const hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv;
+
+const port = [ args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001 ]
+    .map(portValue => parseInt(portValue))
+    .find(portValue => !isNaN(portValue));
+
+const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined;
+const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined;
+const sslKeyPassphrase = args["ssl-key-passphrase"] || process.env.UPTIME_KUMA_SSL_KEY_PASSPHRASE || process.env.SSL_KEY_PASSPHRASE || undefined;
+
+const isSSL = sslKey && sslCert;
+
+function getLocalWebSocketURL() {
+    const protocol = isSSL ? "wss" : "ws";
+    const host = hostname || "localhost";
+    return `${protocol}://${host}:${port}`;
+}
+
+const localWebSocketURL = getLocalWebSocketURL();
+
+const demoMode = args["demo"] || false;
 
 module.exports = {
     args,
+    hostname,
+    port,
+    sslKey,
+    sslCert,
+    sslKeyPassphrase,
+    isSSL,
+    localWebSocketURL,
     demoMode,
-    badgeConstants,
 };

programming_language: JavaScript
filename: api-router.js
change_type: ModificationType.MODIFY
diff: @@ -11,12 +11,11 @@ const { R } = require("redbean-node");
 const apicache = require("../modules/apicache");
 const Monitor = require("../model/monitor");
 const dayjs = require("dayjs");
-const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log } = require("../../src/util");
+const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log, badgeConstants } = require("../../src/util");
 const StatusPage = require("../model/status_page");
 const { UptimeKumaServer } = require("../uptime-kuma-server");
 const { UptimeCacheList } = require("../uptime-cache-list");
 const { makeBadge } = require("badge-maker");
-const { badgeConstants } = require("../config");
 const { Prometheus } = require("../prometheus");
 
 let router = express.Router();

programming_language: JavaScript
filename: status-page-router.js
change_type: ModificationType.MODIFY
diff: @@ -5,7 +5,7 @@ const StatusPage = require("../model/status_page");
 const { allowDevAllOrigin, sendHttpError } = require("../util-server");
 const { R } = require("redbean-node");
 const Monitor = require("../model/monitor");
-const { badgeConstants } = require("../config");
+const { badgeConstants } = require("../../src/util");
 const { makeBadge } = require("badge-maker");
 
 let router = express.Router();

programming_language: TypeScript
filename: server.js
change_type: ModificationType.MODIFY
diff: @@ -81,7 +81,7 @@ const notp = require("notp");
 const base32 = require("thirty-two");
 
 const { UptimeKumaServer } = require("./uptime-kuma-server");
-const server = UptimeKumaServer.getInstance(args);
+const server = UptimeKumaServer.getInstance();
 const io = module.exports.io = server.io;
 const app = server.app;
 
@@ -91,7 +91,7 @@ const Monitor = require("./model/monitor");
 const User = require("./model/user");
 
 log.debug("server", "Importing Settings");
-const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, FBSD, doubleCheckPassword, startE2eTests, shake256, SHAKE256_LENGTH
+const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, doubleCheckPassword, startE2eTests, shake256, SHAKE256_LENGTH
 } = require("./util-server");
 
 log.debug("server", "Importing Notification");
@@ -115,19 +115,13 @@ const passwordHash = require("./password-hash");
 const checkVersion = require("./check-version");
 log.info("server", "Version: " + checkVersion.version);
 
-// If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise.
-// Dual-stack support for (::)
-// Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD
-let hostEnv = FBSD ? null : process.env.HOST;
-let hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv;
+const hostname = config.hostname;
 
 if (hostname) {
     log.info("server", "Custom hostname: " + hostname);
 }
 
-const port = [ args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001 ]
-    .map(portValue => parseInt(portValue))
-    .find(portValue => !isNaN(portValue));
+const port = config.port;
 
 const disableFrameSameOrigin = !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || args["disable-frame-sameorigin"] || false;
 const cloudflaredToken = args["cloudflared-token"] || process.env.UPTIME_KUMA_CLOUDFLARED_TOKEN || undefined;
@@ -1157,6 +1151,8 @@ let needSetup = false;
                 let user = await doubleCheckPassword(socket, password.currentPassword);
                 await user.resetPassword(password.newPassword);
 
+                server.disconnectAllSocketClient(user.id, socket.id);
+
                 callback({
                     ok: true,
                     msg: "Password has been updated successfully.",

programming_language: JavaScript
filename: general-socket-handler.js
change_type: ModificationType.MODIFY
diff: @@ -78,4 +78,14 @@ module.exports.generalSocketHandler = (socket, server) => {
             });
         }
     });
+
+    // Disconnect all other socket clients of the user
+    socket.on("disconnectOtherSocketClients", async () => {
+        try {
+            checkLogin(socket);
+            server.disconnectAllSocketClients(socket.userID, socket.id);
+        } catch (e) {
+            log.warn("disconnectAllSocketClients", e.message);
+        }
+    });
 };

programming_language: JavaScript
filename: uptime-kuma-server.js
change_type: ModificationType.MODIFY
diff: @@ -12,6 +12,7 @@ const { Settings } = require("./settings");
 const dayjs = require("dayjs");
 const childProcessAsync = require("promisify-child-process");
 const path = require("path");
+const { isSSL, sslKey, sslCert, sslKeyPassphrase } = require("./config");
 // DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
 
 /**
@@ -62,22 +63,17 @@ class UptimeKumaServer {
      */
     jwtSecret = null;
 
-    static getInstance(args) {
+    static getInstance() {
         if (UptimeKumaServer.instance == null) {
-            UptimeKumaServer.instance = new UptimeKumaServer(args);
+            UptimeKumaServer.instance = new UptimeKumaServer();
         }
         return UptimeKumaServer.instance;
     }
 
-    constructor(args) {
-        // SSL
-        const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined;
-        const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined;
-        const sslKeyPassphrase = args["ssl-key-passphrase"] || process.env.UPTIME_KUMA_SSL_KEY_PASSPHRASE || process.env.SSL_KEY_PASSPHRASE || undefined;
-
+    constructor() {
         log.info("server", "Creating express and socket.io instance");
         this.app = express();
-        if (sslKey && sslCert) {
+        if (isSSL) {
             log.info("server", "Server Type: HTTPS");
             this.httpServer = https.createServer({
                 key: fs.readFileSync(sslKey),
@@ -422,6 +418,25 @@ class UptimeKumaServer {
             }
         }
     }
+
+    /**
+     * Force connected sockets of a user to refresh and disconnect.
+     * Used for resetting password.
+     * @param {string} userID
+     * @param {string?} currentSocketID
+     */
+    disconnectAllSocketClients(userID, currentSocketID = undefined) {
+        for (const socket of this.io.sockets.sockets.values()) {
+            if (socket.userID === userID && socket.id !== currentSocketID) {
+                try {
+                    socket.emit("refresh");
+                    socket.disconnect();
+                } catch (e) {
+
+                }
+            }
+        }
+    }
 }
 
 module.exports = {

programming_language: TypeScript
filename: util-server.js
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,7 @@
 const tcpp = require("tcp-ping");
 const ping = require("@louislam/ping");
 const { R } = require("redbean-node");
-const { log, genSecret } = require("../src/util");
+const { log, genSecret, badgeConstants } = require("../src/util");
 const passwordHash = require("./password-hash");
 const { Resolver } = require("dns");
 const childProcess = require("child_process");
@@ -9,7 +9,6 @@ const iconv = require("iconv-lite");
 const chardet = require("chardet");
 const mqtt = require("mqtt");
 const chroma = require("chroma-js");
-const { badgeConstants } = require("./config");
 const mssql = require("mssql");
 const { Client } = require("pg");
 const postgresConParse = require("pg-connection-string").parse;

programming_language: TypeScript
filename: BadgeGeneratorDialog.vue
change_type: ModificationType.MODIFY
diff: @@ -135,7 +135,7 @@
 <script lang="ts">
 import { Modal } from "bootstrap";
 import CopyableInput from "./CopyableInput.vue";
-import { default as serverConfig } from "../../server/config.js";
+import { badgeConstants } from "../util.ts";
 
 export default {
     components: {
@@ -230,7 +230,7 @@ export default {
                     "labelColor",
                 ],
             },
-            badgeConstants: serverConfig.badgeConstants,
+            badgeConstants,
         };
     },
 

programming_language: HTML
filename: socket.js
change_type: ModificationType.MODIFY
diff: @@ -288,6 +288,10 @@ export default {
             socket.on("initServerTimezone", () => {
                 socket.emit("initServerTimezone", dayjs.tz.guess());
             });
+
+            socket.on("refresh", () => {
+                location.reload();
+            });
         },
 
         /**

programming_language: JavaScript
filename: EditMonitor.vue
change_type: ModificationType.MODIFY
diff: @@ -848,9 +848,8 @@ import NotificationDialog from "../components/NotificationDialog.vue";
 import DockerHostDialog from "../components/DockerHostDialog.vue";
 import ProxyDialog from "../components/ProxyDialog.vue";
 import TagsManager from "../components/TagsManager.vue";
-import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } from "../util.ts";
+import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts";
 import { hostNameRegexPattern } from "../util-frontend";
-import { sleep } from "../util";
 import HiddenInput from "../components/HiddenInput.vue";
 
 const toast = useToast();

programming_language: HTML
filename: util.js
change_type: ModificationType.MODIFY
diff: @@ -7,7 +7,7 @@
 // Backend uses the compiled file util.js
 // Frontend uses util.ts
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0;
+exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0;
 const dayjs = require("dayjs");
 exports.isDev = process.env.NODE_ENV === "development";
 exports.appName = "Uptime Kuma";
@@ -24,6 +24,25 @@ exports.SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
 exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
 exports.MAX_INTERVAL_SECOND = 2073600; // 24 days
 exports.MIN_INTERVAL_SECOND = 20; // 20 seconds
+exports.badgeConstants = {
+    naColor: "#999",
+    defaultUpColor: "#66c20a",
+    defaultWarnColor: "#eed202",
+    defaultDownColor: "#c2290a",
+    defaultPendingColor: "#f8a306",
+    defaultMaintenanceColor: "#1747f5",
+    defaultPingColor: "blue",
+    defaultStyle: "flat",
+    defaultPingValueSuffix: "ms",
+    defaultPingLabelSuffix: "h",
+    defaultUptimeValueSuffix: "%",
+    defaultUptimeLabelSuffix: "h",
+    defaultCertExpValueSuffix: " days",
+    defaultCertExpLabelSuffix: "h",
+    // Values Come From Default Notification Times
+    defaultCertExpireWarnDays: "14",
+    defaultCertExpireDownDays: "7"
+};
 /** Flip the status of s */
 function flipStatus(s) {
     if (s === exports.UP) {

programming_language: JavaScript
filename: util.ts
change_type: ModificationType.MODIFY
diff: @@ -29,6 +29,26 @@ export const SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
 export const MAX_INTERVAL_SECOND = 2073600; // 24 days
 export const MIN_INTERVAL_SECOND = 20; // 20 seconds
 
+export const badgeConstants = {
+    naColor: "#999",
+    defaultUpColor: "#66c20a",
+    defaultWarnColor: "#eed202",
+    defaultDownColor: "#c2290a",
+    defaultPendingColor: "#f8a306",
+    defaultMaintenanceColor: "#1747f5",
+    defaultPingColor: "blue",  // as defined by badge-maker / shields.io
+    defaultStyle: "flat",
+    defaultPingValueSuffix: "ms",
+    defaultPingLabelSuffix: "h",
+    defaultUptimeValueSuffix: "%",
+    defaultUptimeLabelSuffix: "h",
+    defaultCertExpValueSuffix: " days",
+    defaultCertExpLabelSuffix: "h",
+    // Values Come From Default Notification Times
+    defaultCertExpireWarnDays: "14",
+    defaultCertExpireDownDays: "7"
+};
+
 /** Flip the status of s */
 export function flipStatus(s: number) {
     if (s === UP) {

programming_language: TypeScript