mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
COD-54 gated the /api/hook-event + /api/status-telemetry localhost bypass behind the shared X-Codeman-Hook-Secret only WHILE a managed tunnel was running, keeping a plain localhost bypass otherwise. But Codeman can't detect a user's OWN loopback reverse proxy (their own `cloudflared --url`, `tailscale serve`, nginx -> 127.0.0.1), which proxies internet traffic into the loopback origin with req.ip === 127.0.0.1 — so that setup kept the unsafe plain bypass. Require the secret on the loopback bypass unconditionally. Managed-session hooks already always present it (X-Codeman-Hook-Secret from $CODEMAN_HOOK_SECRET_FILE, generated for every instance), so the legitimate hook channel is unaffected; only the previously-unguarded own-proxy path is now rejected. Drops the now-unused getTunnelRunning param from registerAuthMiddleware. Tests: cod54-hook-event-auth (tunnel-down now also requires the secret, plus a good-secret positive case); auth-security (hook tests present the secret to reach schema validation).
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
/**
|
|
* 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 fails closed when network-bound without auth
|
|
* 5. SSE client limit enforcement
|
|
* 6. Logout endpoint invalidates session
|
|
* 7. Settings schema rejects unknown fields
|
|
*
|
|
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import { TmuxManager } from '../src/tmux-manager.js';
|
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
|
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
|
|
|
const AUTH_PORT = 3160;
|
|
const NOAUTH_PORT = 3161;
|
|
const NETWORK_OVERRIDE_PORT = 3162;
|
|
const AUTH_RATE_LIMIT_PORT = 3220;
|
|
const NOAUTH_NETWORK_PORT = 3221;
|
|
const TEST_USER = 'admin';
|
|
const TEST_PASS = 'test-password-12345';
|
|
|
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
|
|
|
function basicAuthHeader(user: string, pass: string): string {
|
|
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
|
}
|
|
|
|
async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
|
|
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
|
const server = new WebServer(port, false, true);
|
|
await server.start();
|
|
return { server, baseUrl: `http://localhost:${port}` };
|
|
}
|
|
|
|
async function getSessionCookie(baseUrl: string): Promise<string> {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const setCookie = res.headers.get('set-cookie');
|
|
expect(setCookie).toBeTruthy();
|
|
const cookieMatch = setCookie!.match(/codeman_session=([^;]+)/);
|
|
expect(cookieMatch).toBeTruthy();
|
|
return `codeman_session=${cookieMatch![1]}`;
|
|
}
|
|
|
|
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
|
|
for (let i = 0; i < 10; i++) {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
}
|
|
}
|
|
|
|
describe('Auth Security', () => {
|
|
let server: WebServer;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
|
server = new WebServer(AUTH_PORT, false, true);
|
|
await server.start();
|
|
baseUrl = `http://localhost:${AUTH_PORT}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.stop();
|
|
delete process.env.CODEMAN_PASSWORD;
|
|
delete process.env.CODEMAN_USERNAME;
|
|
});
|
|
|
|
describe('Basic Auth', () => {
|
|
it('should reject requests without credentials', async () => {
|
|
const res = await fetch(`${baseUrl}/api/status`);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('should accept correct credentials', async () => {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('should reject wrong password', async () => {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong') },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('should reject wrong username', async () => {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader('hacker', TEST_PASS) },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('should reject empty authorization header', async () => {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Authorization: '' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('should reject malformed authorization header', async () => {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Authorization: 'Bearer some-token' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('Session Cookies', () => {
|
|
it('should issue session cookie on successful auth', async () => {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const setCookie = res.headers.get('set-cookie');
|
|
expect(setCookie).toBeTruthy();
|
|
expect(setCookie).toContain('codeman_session=');
|
|
expect(setCookie).toContain('HttpOnly');
|
|
expect(setCookie).toContain('SameSite=Lax');
|
|
});
|
|
|
|
it('should accept requests with valid session cookie', async () => {
|
|
// First, 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]}`;
|
|
|
|
// Use the cookie without Basic Auth header
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Cookie: cookie },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('should reject requests with invalid session cookie', async () => {
|
|
const res = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Cookie: 'codeman_session=invalid-token-value' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
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', () => {
|
|
let rateServer: WebServer;
|
|
let rateBaseUrl: string;
|
|
|
|
beforeEach(async () => {
|
|
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rateServer.stop();
|
|
});
|
|
|
|
it('should rate-limit wrong credentials after too many failed attempts', async () => {
|
|
await exhaustAuthFailures(rateBaseUrl, 'cod21-wrong');
|
|
|
|
const res = await fetch(`${rateBaseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') },
|
|
});
|
|
|
|
expect(res.status).toBe(429);
|
|
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
|
|
});
|
|
|
|
it('should allow an existing valid session cookie during auth failure lockout', async () => {
|
|
const cookie = await getSessionCookie(rateBaseUrl);
|
|
await exhaustAuthFailures(rateBaseUrl, 'cod21-cookie');
|
|
|
|
const res = await fetch(`${rateBaseUrl}/api/status`, {
|
|
headers: { Cookie: cookie },
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('should allow correct credentials to recover from auth failure lockout', async () => {
|
|
await exhaustAuthFailures(rateBaseUrl, 'cod21-recover');
|
|
|
|
const res = await fetch(`${rateBaseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('set-cookie')).toContain('codeman_session=');
|
|
});
|
|
|
|
it('should clear failed attempt count after correct credentials recover access', async () => {
|
|
await exhaustAuthFailures(rateBaseUrl, 'cod21-clear');
|
|
|
|
const recoveryRes = await fetch(`${rateBaseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
|
});
|
|
expect(recoveryRes.status).toBe(200);
|
|
|
|
const wrongAfterRecovery = await fetch(`${rateBaseUrl}/api/status`, {
|
|
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-after-recovery') },
|
|
});
|
|
|
|
expect(wrongAfterRecovery.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('Hook Event Endpoint', () => {
|
|
it('should allow hook events from localhost with the hook secret (no Basic auth)', async () => {
|
|
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
|
|
body: JSON.stringify({
|
|
event: 'stop',
|
|
sessionId: 'nonexistent-session',
|
|
data: {},
|
|
}),
|
|
});
|
|
// Should pass auth (localhost bypass + hook secret) but may 404 on session — that's fine.
|
|
// The key assertion is it does NOT return 401 (COD-91: secret required even with no tunnel).
|
|
expect(res.status).not.toBe(401);
|
|
});
|
|
|
|
it('should reject hook events with invalid schema', async () => {
|
|
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
|
|
body: JSON.stringify({ invalid: 'data' }),
|
|
});
|
|
// Past the auth gate (valid secret) → schema validation should catch this (not a 401).
|
|
expect(res.status).not.toBe(401); // Not an auth error
|
|
});
|
|
});
|
|
});
|
|
|
|
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 Startup Policy', () => {
|
|
let server: WebServer;
|
|
|
|
beforeAll(async () => {
|
|
delete process.env.CODEMAN_PASSWORD;
|
|
delete process.env.CODEMAN_USERNAME;
|
|
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
|
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
|
|
await server.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
delete process.env.CODEMAN_PASSWORD;
|
|
delete process.env.CODEMAN_USERNAME;
|
|
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
|
await server.stop();
|
|
});
|
|
|
|
it('allows loopback requests without auth when no password is configured', async () => {
|
|
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('starts with a loud warning (not a hard failure) on a non-loopback bind without a password', async () => {
|
|
// Policy (0.9.0): loopback is the safe default, but opting into a non-loopback
|
|
// bind without a password no longer refuses to start — it starts and warns,
|
|
// pointing at how to secure it. See docs/security-architecture.md.
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0');
|
|
|
|
await expect(networkServer.start()).resolves.toBeUndefined();
|
|
const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`);
|
|
expect(res.status).toBe(200);
|
|
|
|
const warned = warnSpy.mock.calls.flat().join('\n');
|
|
expect(warned).toMatch(/non-loopback host|NO password/i);
|
|
expect(warned).toMatch(/CODEMAN_PASSWORD/);
|
|
|
|
warnSpy.mockRestore();
|
|
await networkServer.stop();
|
|
});
|
|
|
|
it('allows non-loopback startup when CODEMAN_PASSWORD is configured', async () => {
|
|
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
|
const networkServer = new WebServer(0, false, true, '0.0.0.0');
|
|
|
|
await networkServer.start();
|
|
await networkServer.stop();
|
|
|
|
delete process.env.CODEMAN_PASSWORD;
|
|
});
|
|
|
|
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
|
|
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
|
|
|
|
await networkServer.start();
|
|
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
|
|
expect(res.status).toBe(200);
|
|
await networkServer.stop();
|
|
});
|
|
|
|
it('allows non-loopback startup with the explicit unauthenticated-network env override', async () => {
|
|
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = 'true';
|
|
const networkServer = new WebServer(0, false, true, '0.0.0.0');
|
|
|
|
await networkServer.start();
|
|
await networkServer.stop();
|
|
|
|
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
|
});
|
|
});
|