From 8fc0dc8c2e91707f365c443986960cc7b52ec295 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 27 Feb 2026 01:13:58 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- package.json | 2 +- src/config/map-limits.ts | 10 ++++ src/web/schemas.ts | 69 +++++++++++++++++++++++-- src/web/server.ts | 26 ++++++++-- test/auth-security.test.ts | 100 ++++++++++++++++++++++++++++++++++++- 6 files changed, 200 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 827922c0..47b50c10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart codeman-web` -**Version**: 0.1654 (must match `package.json`) +**Version**: 0.1655 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index 05b52c37..7c58f559 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeman", - "version": "0.1654", + "version": "0.1655", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index 1ea35543..3fb509fb 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -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) // ============================================================================ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 3189cf91..91308eb9 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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 diff --git a/src/web/server.ts b/src/web/server.ts index b1b19b37..06f7c748 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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()); diff --git a/test/auth-security.test.ts b/test/auth-security.test.ts index 9d5db08a..db3ed2d1 100644 --- a/test/auth-security.test.ts +++ b/test/auth-security.test.ts @@ -1,14 +1,18 @@ /** - * Auth security tests — verifies critical security fixes: + * Auth security tests — verifies security fixes: * 1. Timing-safe password comparison (timingSafeEqual) * 2. Hook event endpoint restricted to localhost * 3. Session cookie TTL refresh on access * 4. Startup warning when no password configured + * 5. SSE client limit enforcement + * 6. Logout endpoint invalidates session + * 7. Settings schema rejects unknown fields * * Port: 3160 (auth tests), 3161 (no-auth tests) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebServer } from '../src/web/server.js'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; const AUTH_PORT = 3160; const NOAUTH_PORT = 3161; @@ -117,6 +121,38 @@ describe('Auth Security', () => { }); }); + describe('Logout', () => { + it('should invalidate session cookie on logout', async () => { + // Authenticate to get a cookie + const authRes = await fetch(`${baseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + const setCookie = authRes.headers.get('set-cookie')!; + const cookieMatch = setCookie.match(/codeman_session=([^;]+)/); + expect(cookieMatch).toBeTruthy(); + const cookie = `codeman_session=${cookieMatch![1]}`; + + // Verify cookie works + const beforeRes = await fetch(`${baseUrl}/api/status`, { + headers: { Cookie: cookie }, + }); + expect(beforeRes.status).toBe(200); + + // Logout + const logoutRes = await fetch(`${baseUrl}/api/logout`, { + method: 'POST', + headers: { Cookie: cookie }, + }); + expect(logoutRes.status).toBe(200); + + // Cookie should no longer work + const afterRes = await fetch(`${baseUrl}/api/status`, { + headers: { Cookie: cookie }, + }); + expect(afterRes.status).toBe(401); + }); + }); + describe('Rate Limiting', () => { it('should block after too many failed attempts', async () => { // Send 10 failed attempts @@ -172,6 +208,68 @@ describe('Auth Security', () => { }); }); +describe('Settings Schema Security', () => { + it('should accept valid known settings fields', () => { + const result = SettingsUpdateSchema.safeParse({ + tunnelEnabled: true, + ralphTrackerEnabled: false, + defaultClaudeMdPath: '/some/path', + }); + expect(result.success).toBe(true); + }); + + it('should enforce tunnelEnabled as boolean', () => { + const result = SettingsUpdateSchema.safeParse({ + tunnelEnabled: 'yes', // truthy string — should be rejected + }); + expect(result.success).toBe(false); + }); + + it('should reject unknown fields (strict mode)', () => { + const result = SettingsUpdateSchema.safeParse({ + tunnelEnabled: true, + maliciousField: 'injected', + }); + expect(result.success).toBe(false); + }); + + it('should accept notification preferences', () => { + const result = SettingsUpdateSchema.safeParse({ + notificationPreferences: { + enabled: true, + browserNotifications: true, + audioAlerts: false, + eventTypes: { + stop: { enabled: true, browser: true, audio: false }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('should accept voice settings', () => { + const result = SettingsUpdateSchema.safeParse({ + voiceSettings: { + apiKey: 'some-key', + language: 'en-US', + }, + }); + expect(result.success).toBe(true); + }); + + it('should validate nice value range', () => { + const validResult = SettingsUpdateSchema.safeParse({ + nice: { enabled: true, niceValue: 10 }, + }); + expect(validResult.success).toBe(true); + + const invalidResult = SettingsUpdateSchema.safeParse({ + nice: { enabled: true, niceValue: 100 }, // Out of range + }); + expect(invalidResult.success).toBe(false); + }); +}); + describe('No-Auth Server Warning', () => { let server: WebServer; let consoleWarnSpy: string[] = [];