mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 16:37:40 +00:00
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:
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
102
server/index.js
102
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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "メッセージをコピー",
|
||||
"copied": "メッセージをコピーしました"
|
||||
"copied": "メッセージをコピーしました",
|
||||
"selectFormat": "コピー形式を選択",
|
||||
"copyAsMarkdown": "Markdownとしてコピー",
|
||||
"copyAsText": "テキストとしてコピー"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "메시지 복사",
|
||||
"copied": "메시지 복사됨"
|
||||
"copied": "메시지 복사됨",
|
||||
"selectFormat": "복사 형식 선택",
|
||||
"copyAsMarkdown": "마크다운으로 복사",
|
||||
"copyAsText": "텍스트로 복사"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "Копировать сообщение",
|
||||
"copied": "Сообщение скопировано"
|
||||
"copied": "Сообщение скопировано",
|
||||
"selectFormat": "Выбрать формат копирования",
|
||||
"copyAsMarkdown": "Копировать как Markdown",
|
||||
"copyAsText": "Копировать как текст"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "П",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "复制消息",
|
||||
"copied": "消息已复制"
|
||||
"copied": "消息已复制",
|
||||
"selectFormat": "选择复制格式",
|
||||
"copyAsMarkdown": "复制为 Markdown",
|
||||
"copyAsText": "复制为纯文本"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user