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;