mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user