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
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
};

View File

@@ -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);
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
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',

View File

@@ -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;

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
});
};