Cloud CLI WebSocket shell injection
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.
- NVD - CVE-2026-31975
- GitHub - siteboon/claudecodeui: Use Claude Code, Cursor CLI or Codex on mobile and web with CloudCLI (aka Claude Code UI). CloudCLI is a free open source webui/GUI that helps you manage your Claude Code session and projects remotely
- Unauthenticated RCE via WebSocket shell injection
- Merge commit from fork · siteboon/claudecodeui@12e7f07
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.
| Package | Affected versions | Patched versions |
|---|---|---|
@siteboon/claude-code-uinpm | < 1.25.0 | 1.25.0 |
Affected products
2- Range: <1.25.0
- siteboon/claudecodeuiv5Range: < 1.25.0
Patches
112e7f074d956Merge commit from fork
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- github.com/advisories/GHSA-gv8f-wpm2-m5wrghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-31975ghsaADVISORY
- github.com/siteboon/claudecodeui/commit/12e7f074d9563b3264caf9cec6e1b701c301af26ghsax_refsource_MISCWEB
- github.com/siteboon/claudecodeui/releases/tag/v1.25.0ghsax_refsource_MISCWEB
- github.com/siteboon/claudecodeui/security/advisories/GHSA-gv8f-wpm2-m5wrghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.