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
This commit is contained in:
Simos Mikelatos
2026-03-10 17:23:55 +01:00
committed by GitHub
parent e52e1a2b58
commit 12e7f074d9
10 changed files with 144 additions and 77 deletions

View File

@@ -59,6 +59,15 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
// Create database connection // Create database connection
const db = new Database(DB_PATH); 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 // Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..'); const appInstallPath = path.join(__dirname, '../..');
console.log(''); console.log('');
@@ -91,6 +100,13 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); 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) // Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names ( db.exec(`CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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 // Backward compatibility - keep old names pointing to new system
const githubTokensDb = { const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => { createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -441,5 +484,6 @@ export {
credentialsDb, credentialsDb,
sessionNamesDb, sessionNamesDb,
applyCustomSessionNames, applyCustomSessionNames,
appConfigDb,
githubTokensDb // Backward compatibility githubTokensDb // Backward compatibility
}; };

View File

@@ -63,3 +63,10 @@ CREATE TABLE IF NOT EXISTS session_names (
); );
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider); 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
);

View File

@@ -326,7 +326,7 @@ const wss = new WebSocketServer({
// Make WebSocket server available to routes // Make WebSocket server available to routes
app.locals.wss = wss; app.locals.wss = wss;
app.use(cors()); app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(express.json({ app.use(express.json({
limit: '50mb', limit: '50mb',
type: (req) => { type: (req) => {
@@ -1699,50 +1699,43 @@ function handleShellConnection(ws) {
})); }));
try { 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; let shellCommand;
if (isPlainShell) { if (isPlainShell) {
// Plain shell mode - just run the initial command in the project directory // Plain shell mode - run the initial command in the project directory
if (os.platform() === 'win32') { shellCommand = initialCommand;
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
} else {
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
}
} else if (provider === 'cursor') { } else if (provider === 'cursor') {
// Use cursor-agent command if (hasSession && sessionId) {
if (os.platform() === 'win32') { shellCommand = `cursor-agent --resume="${sessionId}"`;
if (hasSession && sessionId) {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
}
} else { } else {
if (hasSession && sessionId) { shellCommand = 'cursor-agent';
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `cd "${projectPath}" && cursor-agent`;
}
} }
} else if (provider === 'codex') { } else if (provider === 'codex') {
// Use codex command if (hasSession && sessionId) {
if (os.platform() === 'win32') { shellCommand = `codex resume "${sessionId}" || codex`;
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`;
}
} else { } else {
if (hasSession && sessionId) { shellCommand = 'codex';
// 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`;
}
} }
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
// Use gemini command
const command = initialCommand || 'gemini'; const command = initialCommand || 'gemini';
let resumeId = sessionId; let resumeId = sessionId;
if (hasSession && sessionId) { if (hasSession && sessionId) {
@@ -1753,41 +1746,28 @@ function handleShellConnection(ws) {
const sess = sessionManager.getSession(sessionId); const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) { if (sess && sess.cliSessionId) {
resumeId = sess.cliSessionId; resumeId = sess.cliSessionId;
// Validate the looked-up CLI session ID too
if (!safeSessionIdPattern.test(resumeId)) {
resumeId = null;
}
} }
} catch (err) { } catch (err) {
console.error('Failed to get Gemini CLI session ID:', err); console.error('Failed to get Gemini CLI session ID:', err);
} }
} }
if (os.platform() === 'win32') { if (hasSession && resumeId) {
if (hasSession && resumeId) { shellCommand = `${command} --resume "${resumeId}"`;
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
} else { } else {
if (hasSession && resumeId) { shellCommand = command;
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
} }
} else { } else {
// Use claude command (default) or initialCommand if provided // Claude (default provider)
const command = initialCommand || 'claude'; const command = initialCommand || 'claude';
if (os.platform() === 'win32') { if (hasSession && sessionId) {
if (hasSession && sessionId) { shellCommand = `claude --resume "${sessionId}" || claude`;
// 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}`;
}
} else { } else {
if (hasSession && sessionId) { shellCommand = command;
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
} }
} }
@@ -1806,7 +1786,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color', name: 'xterm-256color',
cols: termCols, cols: termCols,
rows: termRows, rows: termRows,
cwd: os.homedir(), cwd: resolvedProjectPath,
env: { env: {
...process.env, ...process.env,
TERM: 'xterm-256color', TERM: 'xterm-256color',

View File

@@ -1,9 +1,9 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { userDb } from '../database/db.js'; import { userDb, appConfigDb } from '../database/db.js';
import { IS_PLATFORM } from '../constants/config.js'; import { IS_PLATFORM } from '../constants/config.js';
// Get JWT secret from environment or use default (for development) // Use env var if set, otherwise auto-generate a unique secret per installation
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production'; const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
// Optional API key middleware // Optional API key middleware
const validateApiKey = (req, res, next) => { 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.' }); 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; req.user = user;
next(); next();
} catch (error) { } catch (error) {
@@ -66,15 +76,15 @@ const authenticateToken = async (req, res, next) => {
} }
}; };
// Generate JWT token (never expires) // Generate JWT token
const generateToken = (user) => { const generateToken = (user) => {
return jwt.sign( return jwt.sign(
{ {
userId: user.id, userId: user.id,
username: user.username username: user.username
}, },
JWT_SECRET JWT_SECRET,
// No expiration - token lasts forever { expiresIn: '7d' }
); );
}; };
@@ -101,7 +111,12 @@ const authenticateWebSocket = (token) => {
try { try {
const decoded = jwt.verify(token, JWT_SECRET); 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) { } catch (error) {
console.error('WebSocket token verification error:', error); console.error('WebSocket token verification error:', error);
return null; return null;

View File

@@ -6,7 +6,10 @@
}, },
"copyMessage": { "copyMessage": {
"copy": "Copy message", "copy": "Copy message",
"copied": "Message copied" "copied": "Message copied",
"selectFormat": "Select copy format",
"copyAsMarkdown": "Copy as markdown",
"copyAsText": "Copy as text"
}, },
"messageTypes": { "messageTypes": {
"user": "U", "user": "U",

View File

@@ -6,7 +6,10 @@
}, },
"copyMessage": { "copyMessage": {
"copy": "メッセージをコピー", "copy": "メッセージをコピー",
"copied": "メッセージをコピーしました" "copied": "メッセージをコピーしました",
"selectFormat": "コピー形式を選択",
"copyAsMarkdown": "Markdownとしてコピー",
"copyAsText": "テキストとしてコピー"
}, },
"messageTypes": { "messageTypes": {
"user": "U", "user": "U",

View File

@@ -6,7 +6,10 @@
}, },
"copyMessage": { "copyMessage": {
"copy": "메시지 복사", "copy": "메시지 복사",
"copied": "메시지 복사됨" "copied": "메시지 복사됨",
"selectFormat": "복사 형식 선택",
"copyAsMarkdown": "마크다운으로 복사",
"copyAsText": "텍스트로 복사"
}, },
"messageTypes": { "messageTypes": {
"user": "U", "user": "U",

View File

@@ -6,7 +6,10 @@
}, },
"copyMessage": { "copyMessage": {
"copy": "Копировать сообщение", "copy": "Копировать сообщение",
"copied": "Сообщение скопировано" "copied": "Сообщение скопировано",
"selectFormat": "Выбрать формат копирования",
"copyAsMarkdown": "Копировать как Markdown",
"copyAsText": "Копировать как текст"
}, },
"messageTypes": { "messageTypes": {
"user": "П", "user": "П",

View File

@@ -6,7 +6,10 @@
}, },
"copyMessage": { "copyMessage": {
"copy": "复制消息", "copy": "复制消息",
"copied": "消息已复制" "copied": "消息已复制",
"selectFormat": "选择复制格式",
"copyAsMarkdown": "复制为 Markdown",
"copyAsText": "复制为纯文本"
}, },
"messageTypes": { "messageTypes": {
"user": "U", "user": "U",

View File

@@ -21,6 +21,12 @@ export const authenticatedFetch = (url, options = {}) => {
...defaultHeaders, ...defaultHeaders,
...options.headers, ...options.headers,
}, },
}).then((response) => {
const refreshedToken = response.headers.get('X-Refreshed-Token');
if (refreshedToken) {
localStorage.setItem('auth-token', refreshedToken);
}
return response;
}); });
}; };