VYPR
High severityNVD Advisory· Published Mar 11, 2026· Updated Mar 12, 2026

Cloud CLI WebSocket shell injection

CVE-2026-31975

Description

Cloud CLI (aka Claude Code UI) is a desktop and mobile UI for Claude Code, Cursor CLI, Codex, and Gemini-CLI. Prior to 1.25.0, OS Command Injection via WebSocket Shell. Both projectPath and initialCommand in server/index.js are taken directly from the WebSocket message payload and interpolated into a bash command string without any sanitization, enabling arbitrary OS command execution. A secondary injection vector exists via unsanitized sessionId. This vulnerability is fixed in 1.25.0.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Cloud CLI (Claude Code UI) prior to 1.25.0 allows unauthenticated remote code execution via OS command injection in WebSocket shell handler, chained with insecure default JWT secret and authentication bypass.

Vulnerability

Overview

CVE-2026-31975 is a critical OS command injection vulnerability in Cloud CLI (Claude Code UI), a desktop and mobile interface for AI coding agents. The flaw resides in the WebSocket shell handler within server/index.js, where the projectPath and initialCommand fields from incoming WebSocket messages are directly interpolated into a bash command string without any sanitization. A secondary injection vector also exists via an unsanitized sessionId parameter. This allows an attacker to inject arbitrary shell commands that are executed on the server [1][2].

Exploitation

Chain

The vulnerability is exploitable without authentication due to two additional weaknesses. First, the server uses a hardcoded default JWT secret ('claude-ui-dev-secret-change-in-production') when the JWT_SECRET environment variable is not set, which is the common case as it is not included in .env.example [3]. Furthermore, the WebSocket authentication function (authenticateWebSocket()) only verifies the JWT signature but does not validate that the userId in the token exists in the database, unlike the REST endpoint authentication [3]. An attacker can forge a valid JWT using the known default secret, bypass authentication, and then send a crafted WebSocket message containing malicious shell commands in the projectPath or initialCommand fields.

Impact

Successful exploitation allows an unauthenticated remote attacker to execute arbitrary operating system commands on the server with the privileges of the Cloud CLI process. This can lead to full compromise of the host system, including data exfiltration, installation of malware, or lateral movement within the network. The CVSS score is 9.8 (Critical) [3].

Mitigation

The vulnerability is fixed in version 1.25.0. The fix includes proper validation of projectPath (resolving to an absolute path and verifying it exists as a directory), sanitization of sessionId with a whitelist of safe characters, and refactoring the shell command construction to avoid direct interpolation of user input [4]. Users should upgrade immediately. As a workaround, setting a strong, unique JWT_SECRET environment variable mitigates the authentication bypass, but the command injection itself remains exploitable if an attacker obtains a valid token.

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@siteboon/claude-code-uinpm
< 1.25.01.25.0

Affected products

2

Patches

1
12e7f074d956

Merge commit from fork

https://github.com/siteboon/claudecodeuiSimos MikelatosMar 10, 2026via ghsa
10 files changed · +144 77
  • server/database/db.js+44 0 modified
    @@ -59,6 +59,15 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
     // Create database connection
     const db = new Database(DB_PATH);
     
    +// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
    +// runMigrations() also creates this table, but it runs too late for existing installations
    +// where auth.js is imported before initializeDatabase() is called.
    +db.exec(`CREATE TABLE IF NOT EXISTS app_config (
    +  key TEXT PRIMARY KEY,
    +  value TEXT NOT NULL,
    +  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    +)`);
    +
     // Show app installation path prominently
     const appInstallPath = path.join(__dirname, '../..');
     console.log('');
    @@ -91,6 +100,13 @@ const runMigrations = () => {
           db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
         }
     
    +    // Create app_config table if it doesn't exist (for existing installations)
    +    db.exec(`CREATE TABLE IF NOT EXISTS app_config (
    +      key TEXT PRIMARY KEY,
    +      value TEXT NOT NULL,
    +      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    +    )`);
    +
         // Create session_names table if it doesn't exist (for existing installations)
         db.exec(`CREATE TABLE IF NOT EXISTS session_names (
           id INTEGER PRIMARY KEY AUTOINCREMENT,
    @@ -414,6 +430,33 @@ function applyCustomSessionNames(sessions, provider) {
       }
     }
     
    +// App config database operations
    +const appConfigDb = {
    +  get: (key) => {
    +    try {
    +      const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
    +      return row?.value || null;
    +    } catch (err) {
    +      return null;
    +    }
    +  },
    +
    +  set: (key, value) => {
    +    db.prepare(
    +      'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
    +    ).run(key, value);
    +  },
    +
    +  getOrCreateJwtSecret: () => {
    +    let secret = appConfigDb.get('jwt_secret');
    +    if (!secret) {
    +      secret = crypto.randomBytes(64).toString('hex');
    +      appConfigDb.set('jwt_secret', secret);
    +    }
    +    return secret;
    +  }
    +};
    +
     // Backward compatibility - keep old names pointing to new system
     const githubTokensDb = {
       createGithubToken: (userId, tokenName, githubToken, description = null) => {
    @@ -441,5 +484,6 @@ export {
       credentialsDb,
       sessionNamesDb,
       applyCustomSessionNames,
    +  appConfigDb,
       githubTokensDb // Backward compatibility
     };
    \ No newline at end of file
    
  • server/database/init.sql+8 1 modified
    @@ -62,4 +62,11 @@ CREATE TABLE IF NOT EXISTS session_names (
         UNIQUE(session_id, provider)
     );
     
    -CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
    \ No newline at end of file
    +CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
    +
    +-- App configuration table (auto-generated secrets, settings, etc.)
    +CREATE TABLE IF NOT EXISTS app_config (
    +    key TEXT PRIMARY KEY,
    +    value TEXT NOT NULL,
    +    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    +);
    \ No newline at end of file
    
  • server/index.js+41 61 modified
    @@ -326,7 +326,7 @@ const wss = new WebSocketServer({
     // Make WebSocket server available to routes
     app.locals.wss = wss;
     
    -app.use(cors());
    +app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
     app.use(express.json({
         limit: '50mb',
         type: (req) => {
    @@ -1699,50 +1699,43 @@ function handleShellConnection(ws) {
                     }));
     
                     try {
    -                    // Prepare the shell command adapted to the platform and provider
    +                    // Validate projectPath — resolve to absolute and verify it exists
    +                    const resolvedProjectPath = path.resolve(projectPath);
    +                    try {
    +                        const stats = fs.statSync(resolvedProjectPath);
    +                        if (!stats.isDirectory()) {
    +                            throw new Error('Not a directory');
    +                        }
    +                    } catch (pathErr) {
    +                        ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
    +                        return;
    +                    }
    +
    +                    // Validate sessionId — only allow safe characters
    +                    const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
    +                    if (sessionId && !safeSessionIdPattern.test(sessionId)) {
    +                        ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
    +                        return;
    +                    }
    +
    +                    // Build shell command — use cwd for project path (never interpolate into shell string)
                         let shellCommand;
                         if (isPlainShell) {
    -                        // Plain shell mode - just run the initial command in the project directory
    -                        if (os.platform() === 'win32') {
    -                            shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
    -                        } else {
    -                            shellCommand = `cd "${projectPath}" && ${initialCommand}`;
    -                        }
    +                        // Plain shell mode - run the initial command in the project directory
    +                        shellCommand = initialCommand;
                         } else if (provider === 'cursor') {
    -                        // Use cursor-agent command
    -                        if (os.platform() === 'win32') {
    -                            if (hasSession && sessionId) {
    -                                shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
    -                            } else {
    -                                shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
    -                            }
    +                        if (hasSession && sessionId) {
    +                            shellCommand = `cursor-agent --resume="${sessionId}"`;
                             } else {
    -                            if (hasSession && sessionId) {
    -                                shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
    -                            } else {
    -                                shellCommand = `cd "${projectPath}" && cursor-agent`;
    -                            }
    +                            shellCommand = 'cursor-agent';
                             }
    -
                         } else if (provider === 'codex') {
    -                        // Use codex command
    -                        if (os.platform() === 'win32') {
    -                            if (hasSession && sessionId) {
    -                                // Try to resume session, but with fallback to a new session if it fails
    -                                shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
    -                            } else {
    -                                shellCommand = `Set-Location -Path "${projectPath}"; codex`;
    -                            }
    +                        if (hasSession && sessionId) {
    +                            shellCommand = `codex resume "${sessionId}" || codex`;
                             } else {
    -                            if (hasSession && sessionId) {
    -                                // Try to resume session, but with fallback to a new session if it fails
    -                                shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
    -                            } else {
    -                                shellCommand = `cd "${projectPath}" && codex`;
    -                            }
    +                            shellCommand = 'codex';
                             }
                         } else if (provider === 'gemini') {
    -                        // Use gemini command
                             const command = initialCommand || 'gemini';
                             let resumeId = sessionId;
                             if (hasSession && sessionId) {
    @@ -1753,41 +1746,28 @@ function handleShellConnection(ws) {
                                     const sess = sessionManager.getSession(sessionId);
                                     if (sess && sess.cliSessionId) {
                                         resumeId = sess.cliSessionId;
    +                                    // Validate the looked-up CLI session ID too
    +                                    if (!safeSessionIdPattern.test(resumeId)) {
    +                                        resumeId = null;
    +                                    }
                                     }
                                 } catch (err) {
                                     console.error('Failed to get Gemini CLI session ID:', err);
                                 }
                             }
     
    -                        if (os.platform() === 'win32') {
    -                            if (hasSession && resumeId) {
    -                                shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
    -                            } else {
    -                                shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
    -                            }
    +                        if (hasSession && resumeId) {
    +                            shellCommand = `${command} --resume "${resumeId}"`;
                             } else {
    -                            if (hasSession && resumeId) {
    -                                shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
    -                            } else {
    -                                shellCommand = `cd "${projectPath}" && ${command}`;
    -                            }
    +                            shellCommand = command;
                             }
                         } else {
    -                        // Use claude command (default) or initialCommand if provided
    +                        // Claude (default provider)
                             const command = initialCommand || 'claude';
    -                        if (os.platform() === 'win32') {
    -                            if (hasSession && sessionId) {
    -                                // Try to resume session, but with fallback to new session if it fails
    -                                shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
    -                            } else {
    -                                shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
    -                            }
    +                        if (hasSession && sessionId) {
    +                            shellCommand = `claude --resume "${sessionId}" || claude`;
                             } else {
    -                            if (hasSession && sessionId) {
    -                                shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
    -                            } else {
    -                                shellCommand = `cd "${projectPath}" && ${command}`;
    -                            }
    +                            shellCommand = command;
                             }
                         }
     
    @@ -1806,7 +1786,7 @@ function handleShellConnection(ws) {
                             name: 'xterm-256color',
                             cols: termCols,
                             rows: termRows,
    -                        cwd: os.homedir(),
    +                        cwd: resolvedProjectPath,
                             env: {
                                 ...process.env,
                                 TERM: 'xterm-256color',
    
  • server/middleware/auth.js+25 10 modified
    @@ -1,9 +1,9 @@
     import jwt from 'jsonwebtoken';
    -import { userDb } from '../database/db.js';
    +import { userDb, appConfigDb } from '../database/db.js';
     import { IS_PLATFORM } from '../constants/config.js';
     
    -// Get JWT secret from environment or use default (for development)
    -const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
    +// Use env var if set, otherwise auto-generate a unique secret per installation
    +const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
     
     // Optional API key middleware
     const validateApiKey = (req, res, next) => {
    @@ -58,6 +58,16 @@ const authenticateToken = async (req, res, next) => {
           return res.status(401).json({ error: 'Invalid token. User not found.' });
         }
     
    +    // Auto-refresh: if token is past halfway through its lifetime, issue a new one
    +    if (decoded.exp && decoded.iat) {
    +      const now = Math.floor(Date.now() / 1000);
    +      const halfLife = (decoded.exp - decoded.iat) / 2;
    +      if (now > decoded.iat + halfLife) {
    +        const newToken = generateToken(user);
    +        res.setHeader('X-Refreshed-Token', newToken);
    +      }
    +    }
    +
         req.user = user;
         next();
       } catch (error) {
    @@ -66,15 +76,15 @@ const authenticateToken = async (req, res, next) => {
       }
     };
     
    -// Generate JWT token (never expires)
    +// Generate JWT token
     const generateToken = (user) => {
       return jwt.sign(
    -    { 
    -      userId: user.id, 
    -      username: user.username 
    +    {
    +      userId: user.id,
    +      username: user.username
         },
    -    JWT_SECRET
    -    // No expiration - token lasts forever
    +    JWT_SECRET,
    +    { expiresIn: '7d' }
       );
     };
     
    @@ -101,7 +111,12 @@ const authenticateWebSocket = (token) => {
     
       try {
         const decoded = jwt.verify(token, JWT_SECRET);
    -    return decoded;
    +    // Verify user actually exists in database (matches REST authenticateToken behavior)
    +    const user = userDb.getUserById(decoded.userId);
    +    if (!user) {
    +      return null;
    +    }
    +    return { userId: user.id, username: user.username };
       } catch (error) {
         console.error('WebSocket token verification error:', error);
         return null;
    
  • src/i18n/locales/en/chat.json+4 1 modified
    @@ -6,7 +6,10 @@
       },
       "copyMessage": {
         "copy": "Copy message",
    -    "copied": "Message copied"
    +    "copied": "Message copied",
    +    "selectFormat": "Select copy format",
    +    "copyAsMarkdown": "Copy as markdown",
    +    "copyAsText": "Copy as text"
       },
       "messageTypes": {
         "user": "U",
    
  • src/i18n/locales/ja/chat.json+4 1 modified
    @@ -6,7 +6,10 @@
       },
       "copyMessage": {
         "copy": "メッセージをコピー",
    -    "copied": "メッセージをコピーしました"
    +    "copied": "メッセージをコピーしました",
    +    "selectFormat": "コピー形式を選択",
    +    "copyAsMarkdown": "Markdownとしてコピー",
    +    "copyAsText": "テキストとしてコピー"
       },
       "messageTypes": {
         "user": "U",
    
  • src/i18n/locales/ko/chat.json+4 1 modified
    @@ -6,7 +6,10 @@
       },
       "copyMessage": {
         "copy": "메시지 복사",
    -    "copied": "메시지 복사됨"
    +    "copied": "메시지 복사됨",
    +    "selectFormat": "복사 형식 선택",
    +    "copyAsMarkdown": "마크다운으로 복사",
    +    "copyAsText": "텍스트로 복사"
       },
       "messageTypes": {
         "user": "U",
    
  • src/i18n/locales/ru/chat.json+4 1 modified
    @@ -6,7 +6,10 @@
       },
       "copyMessage": {
         "copy": "Копировать сообщение",
    -    "copied": "Сообщение скопировано"
    +    "copied": "Сообщение скопировано",
    +    "selectFormat": "Выбрать формат копирования",
    +    "copyAsMarkdown": "Копировать как Markdown",
    +    "copyAsText": "Копировать как текст"
       },
       "messageTypes": {
         "user": "П",
    
  • src/i18n/locales/zh-CN/chat.json+4 1 modified
    @@ -6,7 +6,10 @@
       },
       "copyMessage": {
         "copy": "复制消息",
    -    "copied": "消息已复制"
    +    "copied": "消息已复制",
    +    "selectFormat": "选择复制格式",
    +    "copyAsMarkdown": "复制为 Markdown",
    +    "copyAsText": "复制为纯文本"
       },
       "messageTypes": {
         "user": "U",
    
  • src/utils/api.js+6 0 modified
    @@ -21,6 +21,12 @@ export const authenticatedFetch = (url, options = {}) => {
           ...defaultHeaders,
           ...options.headers,
         },
    +  }).then((response) => {
    +    const refreshedToken = response.headers.get('X-Refreshed-Token');
    +    if (refreshedToken) {
    +      localStorage.setItem('auth-token', refreshedToken);
    +    }
    +    return response;
       });
     };
     
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.