chore: bump version to 0.1655

Security hardening: timing-safe auth comparison, localhost-only hook bypass,
SSE client limit, TLS key permissions, strict settings schema, logout endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-27 01:13:58 +01:00
co-authored by Claude Opus 4.6
parent f699002cbf
commit 8fc0dc8c2e
6 changed files with 200 additions and 9 deletions
+10
View File
@@ -46,6 +46,16 @@ export const MAX_TOOL_RESULTS_PER_AGENT = 200;
*/
export const MAX_CONCURRENT_SESSIONS = 50;
// ============================================================================
// SSE Client Limits
// ============================================================================
/**
* Maximum concurrent SSE client connections.
* Each connection holds an open HTTP response and receives all broadcast events.
*/
export const MAX_SSE_CLIENTS = 100;
// ============================================================================
// Todo Item Limits (Ralph Tracker)
// ============================================================================
+66 -3
View File
@@ -184,13 +184,76 @@ export const ConfigUpdateSchema = z.object({
/**
* Schema for PUT /api/settings
* User settings with allowed fields only.
* Explicit allowlist of known settings fields — prevents arbitrary key persistence.
*/
const NotificationEventSchema = z.object({
enabled: z.boolean().optional(),
browser: z.boolean().optional(),
audio: z.boolean().optional(),
}).optional();
export const SettingsUpdateSchema = z.object({
// Paths
defaultClaudeMdPath: z.string().max(500).optional(),
defaultWorkingDir: z.string().max(500).optional(),
lastUsedCase: z.string().max(200).optional(),
// Add other known settings fields as needed
}).passthrough(); // Allow additional fields but validate known ones
// Feature toggles
ralphTrackerEnabled: z.boolean().optional(),
subagentTrackingEnabled: z.boolean().optional(),
subagentActiveTabOnly: z.boolean().optional(),
imageWatcherEnabled: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
agentTeamsEnabled: z.boolean().optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
showTokenCount: z.boolean().optional(),
showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(),
showMonitor: z.boolean().optional(),
showProjectInsights: z.boolean().optional(),
showFileBrowser: z.boolean().optional(),
showSubagents: z.boolean().optional(),
// Claude CLI settings
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
// CPU priority
nice: z.object({
enabled: z.boolean().optional(),
niceValue: z.number().int().min(-20).max(19).optional(),
}).optional(),
// Notification preferences (cross-device sync)
notificationPreferences: z.object({
enabled: z.boolean().optional(),
browserNotifications: z.boolean().optional(),
audioAlerts: z.boolean().optional(),
stuckThresholdMs: z.number().optional(),
muteCritical: z.boolean().optional(),
muteWarning: z.boolean().optional(),
muteInfo: z.boolean().optional(),
eventTypes: z.object({
permission_prompt: NotificationEventSchema,
elicitation_dialog: NotificationEventSchema,
idle_prompt: NotificationEventSchema,
stop: NotificationEventSchema,
session_error: NotificationEventSchema,
respawn_cycle: NotificationEventSchema,
token_milestone: NotificationEventSchema,
ralph_complete: NotificationEventSchema,
subagent_spawn: NotificationEventSchema,
subagent_complete: NotificationEventSchema,
}).optional(),
_version: z.number().optional(),
}).optional(),
// Voice settings (cross-device sync)
voiceSettings: z.object({
apiKey: z.string().max(200).optional(),
language: z.string().max(20).optional(),
keyterms: z.string().max(500).optional(),
insertMode: z.string().max(20).optional(),
}).optional(),
}).strict();
/**
* Schema for POST /api/sessions/:id/input with length limit
+23 -3
View File
@@ -16,7 +16,7 @@ import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, chmodSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { randomBytes, timingSafeEqual } from 'node:crypto';
@@ -95,7 +95,7 @@ import {
isValidWorkingDir,
} from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -300,7 +300,7 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } {
};
}
mkdirSync(certsDir, { recursive: true });
mkdirSync(certsDir, { recursive: true, mode: 0o700 });
// Generate self-signed cert valid for 365 days, covering localhost and common LAN access patterns
execSync(
@@ -311,6 +311,9 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } {
{ stdio: 'pipe' }
);
// Restrict private key to owner-only (prevent other local users from reading it)
chmodSync(keyPath, 0o600);
return {
key: readFileSync(keyPath, 'utf-8'),
cert: readFileSync(certPath, 'utf-8'),
@@ -749,6 +752,12 @@ export class WebServer extends EventEmitter {
// SSE endpoint for real-time updates
this.app.get('/api/events', (req, reply) => {
// Enforce SSE client limit to prevent memory exhaustion from too many connections
if (this.sseClients.size >= MAX_SSE_CLIENTS) {
reply.code(503).send('Too many SSE connections');
return;
}
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
@@ -770,6 +779,17 @@ export class WebServer extends EventEmitter {
});
// API Routes
// Logout: invalidate session cookie
this.app.post('/api/logout', async (req, reply) => {
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
if (sessionToken && this.authSessions) {
this.authSessions.delete(sessionToken);
}
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
return { success: true };
});
this.app.get('/api/status', async () => this.getLightState());
this.app.get('/api/tunnel/status', async () => this.tunnelManager.getStatus());