fix: security hardening, SSE robustness, and API consistency

Security:
- Add sanitizeHookData() to whitelist/truncate hook event fields before SSE broadcast
- Add MAX_INPUT_LENGTH (64KB) validation on session input endpoint
- Add MAX_TERMINAL_COLS/ROWS bounds on resize endpoint
- Add MAX_SESSION_NAME_LENGTH on rename endpoint
- Remove Access-Control-Allow-Origin: * from SSE (same-origin only)
- Add X-Accel-Buffering: no header for nginx proxy compatibility

Robustness:
- Add SSE keep-alive comments in health check cycle (prevents proxy timeouts)
- Guard broadcast() against JSON.stringify failures (circular refs, BigInt)
- Prevent concurrent cleanup races with cleaningUp guard set
- Clear stateUpdatePending on session cleanup

API consistency:
- Normalize error responses to use createErrorResponse() with error codes
- Remove dead (session as any).ralphConfig assignment + unused destructuring

Performance:
- Pre-compile CLAUDE_BANNER_PATTERN, CTRL_L_PATTERN, LEADING_WHITESPACE_PATTERN

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-24 04:35:58 +01:00
co-authored by Claude Opus 4.5
parent 2e4320fee7
commit 0ea7a21df2
2 changed files with 128 additions and 48 deletions
+1 -3
View File
@@ -2197,11 +2197,9 @@ class ClaudemanApp {
// Populate Ralph Wiggum form with current session values // Populate Ralph Wiggum form with current session values
const ralphState = this.ralphStates.get(sessionId); const ralphState = this.ralphStates.get(sessionId);
this.populateRalphForm({ this.populateRalphForm({
enabled: ralphState?.loop?.enabled ?? session.ralphConfig?.enabled ?? false, enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '', completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0, maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
maxTodos: session.ralphConfig?.maxTodos || 50,
todoExpirationMinutes: session.ralphConfig?.todoExpirationMinutes || 60
}); });
document.getElementById('sessionOptionsModal').classList.add('active'); document.getElementById('sessionOptionsModal').classList.add('active');
+127 -45
View File
@@ -80,6 +80,63 @@ const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
const MAX_CONCURRENT_SESSIONS = 50; const MAX_CONCURRENT_SESSIONS = 50;
// SSE client health check interval (every 30 seconds) // SSE client health check interval (every 30 seconds)
const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000; const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000;
// Maximum allowed input length for session write (64KB)
const MAX_INPUT_LENGTH = 64 * 1024;
// Maximum terminal resize dimensions
const MAX_TERMINAL_COLS = 500;
const MAX_TERMINAL_ROWS = 200;
// Maximum session name length
const MAX_SESSION_NAME_LENGTH = 128;
// Maximum hook data size (prevents oversized SSE broadcasts)
const MAX_HOOK_DATA_SIZE = 8 * 1024;
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
const CTRL_L_PATTERN = /\x0c/g;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
/**
* Sanitizes hook event data before broadcasting via SSE.
* Extracts only relevant fields and limits total size to prevent
* oversized payloads from being broadcast to all connected clients.
*/
function sanitizeHookData(data: Record<string, unknown> | undefined): Record<string, unknown> {
if (!data || typeof data !== 'object') return {};
// Only forward known safe fields from Claude Code hook stdin
const safeFields: Record<string, unknown> = {};
const allowedKeys = [
'hook_event_name', 'tool_name', 'tool_input', 'session_id',
'cwd', 'permission_mode', 'stop_hook_active',
];
for (const key of allowedKeys) {
if (key in data && data[key] !== undefined) {
safeFields[key] = data[key];
}
}
// For tool_input, extract only summary fields (not full file content)
if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
const input = safeFields.tool_input as Record<string, unknown>;
const summary: Record<string, unknown> = {};
if (input.command) summary.command = String(input.command).slice(0, 500);
if (input.file_path) summary.file_path = String(input.file_path).slice(0, 500);
if (input.description) summary.description = String(input.description).slice(0, 200);
if (input.query) summary.query = String(input.query).slice(0, 200);
if (input.url) summary.url = String(input.url).slice(0, 500);
if (input.pattern) summary.pattern = String(input.pattern).slice(0, 200);
if (input.prompt) summary.prompt = String(input.prompt).slice(0, 200);
safeFields.tool_input = summary;
}
// Final size check - drop if serialized data exceeds limit
const serialized = JSON.stringify(safeFields);
if (serialized.length > MAX_HOOK_DATA_SIZE) {
return { tool_name: safeFields.tool_name, _truncated: true };
}
return safeFields;
}
/** /**
* Auto-configure Ralph tracker for a session. * Auto-configure Ralph tracker for a session.
@@ -241,7 +298,7 @@ export class WebServer extends EventEmitter {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Connection': 'keep-alive', 'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*', 'X-Accel-Buffering': 'no', // Disable nginx buffering
}); });
this.sseClients.add(reply); this.sseClients.add(reply);
@@ -304,10 +361,11 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id); const session = this.sessions.get(id);
if (!session) { if (!session) {
return { error: 'Session not found' }; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
} }
session.name = body.name || ''; const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
session.name = name;
// Also update the screen name if this session has a screen // Also update the screen name if this session has a screen
this.screenManager.updateScreenName(id, session.name); this.screenManager.updateScreenName(id, session.name);
this.persistSessionState(session); this.persistSessionState(session);
@@ -394,12 +452,10 @@ export class WebServer extends EventEmitter {
// Configure Ralph (Ralph Wiggum) settings // Configure Ralph (Ralph Wiggum) settings
this.app.post('/api/sessions/:id/ralph-config', async (req) => { this.app.post('/api/sessions/:id/ralph-config', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { enabled, completionPhrase, maxIterations, maxTodos, todoExpirationMinutes, reset, disableAutoEnable } = req.body as { const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = req.body as {
enabled?: boolean; enabled?: boolean;
completionPhrase?: string; completionPhrase?: string;
maxIterations?: number; maxIterations?: number;
maxTodos?: number;
todoExpirationMinutes?: number;
reset?: boolean | 'full'; // true = soft reset (keep enabled), 'full' = complete reset reset?: boolean | 'full'; // true = soft reset (keep enabled), 'full' = complete reset
disableAutoEnable?: boolean; // Prevent auto-enable on pattern detection disableAutoEnable?: boolean; // Prevent auto-enable on pattern detection
}; };
@@ -450,15 +506,6 @@ export class WebServer extends EventEmitter {
session.ralphTracker.setMaxIterations(maxIterations || null); session.ralphTracker.setMaxIterations(maxIterations || null);
} }
// Store additional config on session for reference
(session as any).ralphConfig = {
enabled: enabled ?? session.ralphTracker.enabled,
completionPhrase: completionPhrase || '',
maxIterations: maxIterations || 0,
maxTodos: maxTodos || 50,
todoExpirationMinutes: todoExpirationMinutes || 60
};
// Persist and broadcast the update // Persist and broadcast the update
this.persistSessionState(session); this.persistSessionState(session);
this.broadcast('session:ralphLoopUpdate', { this.broadcast('session:ralphLoopUpdate', {
@@ -470,17 +517,17 @@ export class WebServer extends EventEmitter {
}); });
// Run prompt in session // Run prompt in session
this.app.post('/api/sessions/:id/run', async (req): Promise<{ success?: boolean; message?: string; error?: string }> => { this.app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { prompt } = req.body as RunPromptRequest; const { prompt } = req.body as RunPromptRequest;
const session = this.sessions.get(id); const session = this.sessions.get(id);
if (!session) { if (!session) {
return { error: 'Session not found' }; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
} }
if (session.isBusy()) { if (session.isBusy()) {
return { error: 'Session is busy' }; return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
} }
// Run async, don't wait // Run async, don't wait
@@ -489,20 +536,20 @@ export class WebServer extends EventEmitter {
}); });
this.broadcast('session:running', { id, prompt }); this.broadcast('session:running', { id, prompt });
return { success: true, message: 'Prompt started' }; return { success: true };
}); });
// Start interactive Claude session (persists even if browser disconnects) // Start interactive Claude session (persists even if browser disconnects)
this.app.post('/api/sessions/:id/interactive', async (req) => { this.app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = this.sessions.get(id); const session = this.sessions.get(id);
if (!session) { if (!session) {
return { error: 'Session not found' }; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
} }
if (session.isBusy()) { if (session.isBusy()) {
return { error: 'Session is busy' }; return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
} }
try { try {
@@ -518,32 +565,32 @@ export class WebServer extends EventEmitter {
this.broadcast('session:interactive', { id }); this.broadcast('session:interactive', { id });
this.broadcast('session:updated', { session: session.toDetailedState() }); this.broadcast('session:updated', { session: session.toDetailedState() });
return { success: true, message: 'Interactive session started' }; return { success: true };
} catch (err) { } catch (err) {
return { error: getErrorMessage(err) }; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
}); });
// Start a plain shell session (no Claude) // Start a plain shell session (no Claude)
this.app.post('/api/sessions/:id/shell', async (req) => { this.app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = this.sessions.get(id); const session = this.sessions.get(id);
if (!session) { if (!session) {
return { error: 'Session not found' }; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
} }
if (session.isBusy()) { if (session.isBusy()) {
return { error: 'Session is busy' }; return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
} }
try { try {
await session.startShell(); await session.startShell();
this.broadcast('session:interactive', { id, mode: 'shell' }); this.broadcast('session:interactive', { id, mode: 'shell' });
this.broadcast('session:updated', { session: session.toDetailedState() }); this.broadcast('session:updated', { session: session.toDetailedState() });
return { success: true, message: 'Shell session started' }; return { success: true };
} catch (err) { } catch (err) {
return { error: getErrorMessage(err) }; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
}); });
@@ -561,7 +608,12 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Input is required'); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Input is required');
} }
session.write(String(input)); const inputStr = String(input);
if (inputStr.length > MAX_INPUT_LENGTH) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
}
session.write(inputStr);
return { success: true }; return { success: true };
}); });
@@ -578,6 +630,9 @@ export class WebServer extends EventEmitter {
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) { if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'cols and rows must be positive integers'); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'cols and rows must be positive integers');
} }
if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})`);
}
session.resize(cols, rows); session.resize(cols, rows);
return { success: true }; return { success: true };
@@ -599,22 +654,20 @@ export class WebServer extends EventEmitter {
let cleanBuffer = session.terminalBuffer; let cleanBuffer = session.terminalBuffer;
// Find where Claude banner starts (has color codes before "Claude") // Find where Claude banner starts (has color codes before "Claude")
// Look for the bold escape sequence followed by "Claude" const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
const claudeMatch = cleanBuffer.match(/\x1b\[1mClaud/);
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) { if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
// Find the start of that line (look for line start or screen positioning before it) // Find the start of that line
let lineStart = claudeMatch.index; let lineStart = claudeMatch.index;
// Go back to find color/positioning sequences that are part of the banner
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') { while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
lineStart--; lineStart--;
} }
cleanBuffer = cleanBuffer.slice(lineStart); cleanBuffer = cleanBuffer.slice(lineStart);
} }
// Also remove any Ctrl+L and leading whitespace // Remove Ctrl+L and leading whitespace
cleanBuffer = cleanBuffer cleanBuffer = cleanBuffer
.replace(/\x0c/g, '') .replace(CTRL_L_PATTERN, '')
.replace(/^[\s\r\n]+/, ''); .replace(LEADING_WHITESPACE_PATTERN, '');
// Optionally truncate to last N bytes for faster initial load // Optionally truncate to last N bytes for faster initial load
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
@@ -1002,7 +1055,7 @@ export class WebServer extends EventEmitter {
const run = this.scheduledRuns.get(id); const run = this.scheduledRuns.get(id);
if (!run) { if (!run) {
return { error: 'Scheduled run not found' }; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
} }
return run; return run;
@@ -1179,7 +1232,7 @@ export class WebServer extends EventEmitter {
const casePath = join(casesDir, name); const casePath = join(casesDir, name);
if (!existsSync(casePath)) { if (!existsSync(casePath)) {
return { error: 'Case not found' }; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
} }
return { return {
@@ -1324,7 +1377,7 @@ export class WebServer extends EventEmitter {
writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
return { error: getErrorMessage(err) }; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
}); });
@@ -1476,7 +1529,9 @@ export class WebServer extends EventEmitter {
} }
} }
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...data }); // Sanitize forwarded data: only include known safe fields, limit size
const safeData = sanitizeHookData(data);
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
return { success: true }; return { success: true };
}); });
} }
@@ -1546,7 +1601,22 @@ export class WebServer extends EventEmitter {
} }
// Clean up all resources associated with a session // Clean up all resources associated with a session
// Track sessions currently being cleaned up to prevent concurrent cleanup races
private cleaningUp: Set<string> = new Set();
private async cleanupSession(sessionId: string, killScreen: boolean = true): Promise<void> { private async cleanupSession(sessionId: string, killScreen: boolean = true): Promise<void> {
// Guard against concurrent cleanup of the same session
if (this.cleaningUp.has(sessionId)) return;
this.cleaningUp.add(sessionId);
try {
await this._doCleanupSession(sessionId, killScreen);
} finally {
this.cleaningUp.delete(sessionId);
}
}
private async _doCleanupSession(sessionId: string, killScreen: boolean): Promise<void> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
// Stop and remove respawn controller // Stop and remove respawn controller
@@ -1566,10 +1636,11 @@ export class WebServer extends EventEmitter {
this.respawnTimers.delete(sessionId); this.respawnTimers.delete(sessionId);
} }
// Clear batches // Clear batches and pending state updates
this.terminalBatches.delete(sessionId); this.terminalBatches.delete(sessionId);
this.outputBatches.delete(sessionId); this.outputBatches.delete(sessionId);
this.taskUpdateBatches.delete(sessionId); this.taskUpdateBatches.delete(sessionId);
this.stateUpdatePending.delete(sessionId);
// Reset Ralph tracker on the session before cleanup // Reset Ralph tracker on the session before cleanup
if (session) { if (session) {
@@ -2077,7 +2148,14 @@ export class WebServer extends EventEmitter {
private broadcast(event: string, data: unknown): void { private broadcast(event: string, data: unknown): void {
// Performance optimization: serialize JSON once for all clients // Performance optimization: serialize JSON once for all clients
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; let message: string;
try {
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
} catch (err) {
// Handle circular references or non-serializable values
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
return;
}
for (const client of this.sseClients) { for (const client of this.sseClients) {
this.sendSSEPreformatted(client, message); this.sendSSEPreformatted(client, message);
} }
@@ -2199,8 +2277,9 @@ export class WebServer extends EventEmitter {
} }
/** /**
* Clean up dead SSE clients that may not have properly disconnected. * Clean up dead SSE clients and send keep-alive comments.
* This prevents memory leaks from abruptly terminated connections. * Keep-alive prevents proxy/load-balancer timeouts on idle connections.
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
*/ */
private cleanupDeadSSEClients(): void { private cleanupDeadSSEClients(): void {
const deadClients: FastifyReply[] = []; const deadClients: FastifyReply[] = [];
@@ -2211,6 +2290,9 @@ export class WebServer extends EventEmitter {
const socket = client.raw.socket || (client.raw as any).connection; const socket = client.raw.socket || (client.raw as any).connection;
if (!socket || socket.destroyed || !socket.writable) { if (!socket || socket.destroyed || !socket.writable) {
deadClients.push(client); deadClients.push(client);
} else {
// Send SSE comment as keep-alive (comments start with ':')
client.raw.write(':keepalive\n\n');
} }
} catch { } catch {
// Error accessing socket means client is dead // Error accessing socket means client is dead