From 12e7f074d9563b3264caf9cec6e1b701c301af26 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Tue, 10 Mar 2026 17:23:55 +0100 Subject: [PATCH] Merge commit from fork * fix(security): prevent shell injection in WebSocket handler and harden auth - Replace hardcoded JWT secret with auto-generated per-installation secret - Add database validation to WebSocket authentication - Add token expiration (7d) with auto-refresh - Validate projectPath and sessionId in shell handler - Use cwd instead of shell string interpolation for project paths - Add CORS exposedHeaders for token refresh * fix: small fix on languages --- server/database/db.js | 44 +++++++++++++ server/database/init.sql | 9 ++- server/index.js | 102 +++++++++++++------------------ server/middleware/auth.js | 35 ++++++++--- src/i18n/locales/en/chat.json | 5 +- src/i18n/locales/ja/chat.json | 5 +- src/i18n/locales/ko/chat.json | 5 +- src/i18n/locales/ru/chat.json | 5 +- src/i18n/locales/zh-CN/chat.json | 5 +- src/utils/api.js | 6 ++ 10 files changed, 144 insertions(+), 77 deletions(-) diff --git a/server/database/db.js b/server/database/db.js index cd112087..bb90c61a 100644 --- a/server/database/db.js +++ b/server/database/db.js @@ -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 diff --git a/server/database/init.sql b/server/database/init.sql index bbab5f40..71ba1bb4 100644 --- a/server/database/init.sql +++ b/server/database/init.sql @@ -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 diff --git a/server/index.js b/server/index.js index e745bf11..521197c1 100755 --- a/server/index.js +++ b/server/index.js @@ -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', diff --git a/server/middleware/auth.js b/server/middleware/auth.js index ab12e0ce..bdfd0f56 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -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; diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index ac56dace..dadaea89 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -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", diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 515aa5dd..10e03192 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -6,7 +6,10 @@ }, "copyMessage": { "copy": "メッセージをコピー", - "copied": "メッセージをコピーしました" + "copied": "メッセージをコピーしました", + "selectFormat": "コピー形式を選択", + "copyAsMarkdown": "Markdownとしてコピー", + "copyAsText": "テキストとしてコピー" }, "messageTypes": { "user": "U", diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 1b533a08..aaf5b45c 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -6,7 +6,10 @@ }, "copyMessage": { "copy": "메시지 복사", - "copied": "메시지 복사됨" + "copied": "메시지 복사됨", + "selectFormat": "복사 형식 선택", + "copyAsMarkdown": "마크다운으로 복사", + "copyAsText": "텍스트로 복사" }, "messageTypes": { "user": "U", diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 1cf514f3..a1c5e277 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -6,7 +6,10 @@ }, "copyMessage": { "copy": "Копировать сообщение", - "copied": "Сообщение скопировано" + "copied": "Сообщение скопировано", + "selectFormat": "Выбрать формат копирования", + "copyAsMarkdown": "Копировать как Markdown", + "copyAsText": "Копировать как текст" }, "messageTypes": { "user": "П", diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 269e9f92..1d224cc7 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -6,7 +6,10 @@ }, "copyMessage": { "copy": "复制消息", - "copied": "消息已复制" + "copied": "消息已复制", + "selectFormat": "选择复制格式", + "copyAsMarkdown": "复制为 Markdown", + "copyAsText": "复制为纯文本" }, "messageTypes": { "user": "U", diff --git a/src/utils/api.js b/src/utils/api.js index 98b26c06..e23c9ef6 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -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; }); };