From 42f0b28c75389c8de042050d3520f8b39ad743bb Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Wed, 10 Jun 2026 12:25:26 -0400 Subject: [PATCH] feat(security): hook-event auth secret + tunnel password guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening fixes for the public-tunnel exposure path (COD-54 / COD-55). COD-54 — gate the /api/hook-event localhost bypass when a tunnel is up: `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the loopback origin, so a tunneled hook request arrives with req.ip === 127.0.0.1 and the old bare-localhost bypass would pass it unauthenticated. Now: - tunnel running → bypass requires a shared per-instance hook secret (X-Codeman-Hook-Secret header; constant-time compare) + per-IP rate limiting - tunnel not running (loopback-only, the normal case) → unchanged, so already-deployed credential-less hooks keep working. New src/config/hook-secret.ts; auth middleware takes a getTunnelRunning probe (wired from server.ts via tunnelManager.isRunning()). COD-55 — refuse starting the Cloudflare tunnel without auth: enabling the tunnel publishes full terminal control to a public URL; with no CODEMAN_PASSWORD the auth middleware is inactive and the bind guard never trips (tunnel binds loopback). PUT /api/settings now refuses tunnelEnabled:true with a 403 (before persisting) unless CODEMAN_PASSWORD is set or CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 is acknowledged. New isUnauthenticatedNetworkAcknowledged() in network-auth-policy; settings-ui surfaces the refusal as an error toast and reverts the toggle. Scope: the always-on CSRF/Origin guard, Host-header allowlist, and network-auth-policy itself are already upstream (#113) and not re-proposed here. Verification: tsc, eslint, prettier, check:frontend-syntax clean; full test:ci green (2723 passed), incl. test/cod54-hook-event-auth and test/routes/system-routes-tunnel-guard. --- src/config/hook-secret.ts | 67 ++++++++ src/web/middleware/auth.ts | 55 ++++++- src/web/network-auth-policy.ts | 10 ++ src/web/public/settings-ui.js | 57 ++++++- src/web/routes/system-routes.ts | 21 +++ src/web/server.ts | 2 +- test/cod54-hook-event-auth.test.ts | 150 ++++++++++++++++++ .../routes/system-routes-tunnel-guard.test.ts | 133 ++++++++++++++++ 8 files changed, 485 insertions(+), 10 deletions(-) create mode 100644 src/config/hook-secret.ts create mode 100644 test/cod54-hook-event-auth.test.ts create mode 100644 test/routes/system-routes-tunnel-guard.test.ts diff --git a/src/config/hook-secret.ts b/src/config/hook-secret.ts new file mode 100644 index 00000000..2f0f8605 --- /dev/null +++ b/src/config/hook-secret.ts @@ -0,0 +1,67 @@ +/** + * @fileoverview Per-instance shared hook secret (COD-54). + * + * Claude Code hooks POST to `/api/hook-event` with no Basic-Auth credentials, + * relying on a localhost bypass in `web/middleware/auth.ts`. That bypass is safe + * for loopback-only deploys, but a `cloudflared --url http://127.0.0.1:port` + * tunnel proxies internet traffic INTO the loopback origin, so tunneled requests + * arrive with `req.ip === 127.0.0.1` and would otherwise pass the bypass and + * drive respawn/Ralph signals unauthenticated. + * + * To close that hole WITHOUT breaking the loop's own (credential-less) hook + * channel, every locally-generated hook command now presents a per-instance + * shared secret in the `X-Codeman-Hook-Secret` header. The middleware requires + * a matching secret for the bypass WHEN A TUNNEL IS RUNNING. Tunneled internet + * traffic can't know the secret; local hooks (which we generate) do. + * + * Storage mirrors the VAPID-key pattern in `push-store.ts`: a small file under + * the instance data dir (`dataPath('hook-secret')`), read-if-present / + * generate-if-missing, stable across restarts. 256 bits of hex. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { getDataDir, dataPath } from './instance.js'; + +/** HTTP header local hooks use to present the shared secret. */ +export const HOOK_SECRET_HEADER = 'X-Codeman-Hook-Secret'; + +/** Number of random bytes in the secret (256 bits → 64 hex chars). */ +const SECRET_BYTES = 32; + +let cachedSecret: string | null = null; + +/** + * Return this instance's hook secret, generating and persisting it on first use. + * Stable across restarts. Cached in-process after the first read. + */ +export function getHookSecret(): string { + if (cachedSecret) return cachedSecret; + + const secretFile = dataPath('hook-secret'); + + if (existsSync(secretFile)) { + try { + const raw = readFileSync(secretFile, 'utf-8').trim(); + if (raw) { + cachedSecret = raw; + return cachedSecret; + } + // Empty/whitespace file — fall through and regenerate. + } catch { + // Unreadable — fall through and regenerate. + } + } + + const secret = randomBytes(SECRET_BYTES).toString('hex'); + try { + mkdirSync(getDataDir(), { recursive: true }); + // Owner-only perms — the secret gates the hook bypass. + writeFileSync(secretFile, secret, { mode: 0o600 }); + } catch { + // Best-effort persistence: even if the write fails we still return a usable + // secret for this process so hooks/middleware agree within this run. + } + cachedSecret = secret; + return cachedSecret; +} diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 1e2a6b27..4671823d 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -19,6 +19,7 @@ import { AUTH_FAILURE_MAX, AUTH_FAILURE_WINDOW_MS, } from '../../config/auth-config.js'; +import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js'; // Auth session cookie name export const AUTH_COOKIE_NAME = 'codeman_session'; @@ -34,9 +35,20 @@ interface AuthState { * Register HTTP Basic Auth middleware with session cookies and rate limiting. * Only active when CODEMAN_PASSWORD is set. * + * @param getTunnelRunning - returns true while a managed tunnel is active. Used + * to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled + * internet traffic reaches the loopback origin with `req.ip === 127.0.0.1`, so + * the bypass additionally requires the shared hook secret (COD-54). When no + * tunnel is running (loopback-only, the normal case) the plain localhost bypass + * is kept so already-deployed (pre-secret) hooks + the loop channel keep working. + * Optional; defaults to "no tunnel" (unchanged behavior) when omitted. * @returns AuthState for lifecycle management (dispose on server stop) */ -export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState { +export function registerAuthMiddleware( + app: FastifyInstance, + https: boolean, + getTunnelRunning: () => boolean = () => false +): AuthState { const state: AuthState = { authSessions: null, authFailures: null, @@ -78,13 +90,44 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au } app.addHook('onRequest', (req, reply, done) => { - // Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available. - // Safe: validated by HookEventSchema, only triggers broadcasts. - // Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN. + // Hook events come from local Claude Code hooks (curl from localhost) — no + // Basic-Auth credentials available. Validated downstream by HookEventSchema. + // + // COD-54: the bare localhost bypass is unsafe while a tunnel is running, because + // `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the + // loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and + // would pass. So: + // - tunnel running → bypass requires the shared hook secret (local hooks present + // it via the X-Codeman-Hook-Secret header; internet traffic can't know it), + // - tunnel not running (loopback-only, the normal case) → keep the plain + // localhost bypass so already-deployed (pre-secret) hooks + the loop's own + // credential-less hook channel keep working. if (req.url === '/api/hook-event' && req.method === 'POST') { const ip = req.ip; - if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') { - done(); + const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; + if (isLoopback) { + if (!getTunnelRunning()) { + // Loopback-only: unchanged behavior. + done(); + return; + } + // Tunnel up: require the shared secret (constant-time compare). + const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? ''); + const expected = Buffer.from(getHookSecret()); + if (presented.length === expected.length && timingSafeEqual(presented, expected)) { + done(); + return; + } + // Wrong/absent secret while tunneled — treat as a failed auth attempt so the + // per-IP rate limiter (below) throttles brute-force/abuse of this route. + const hookIp = req.ip; + const hookFailures = authFailures.get(hookIp) ?? 0; + if (hookFailures >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, hookIp); + return; + } + authFailures.set(hookIp, hookFailures + 1); + reply.code(401).send('Unauthorized: hook secret required'); return; } // Non-localhost hook requests fall through to normal auth diff --git a/src/web/network-auth-policy.ts b/src/web/network-auth-policy.ts index 91240a74..a7e73423 100644 --- a/src/web/network-auth-policy.ts +++ b/src/web/network-auth-policy.ts @@ -6,6 +6,16 @@ export function isExplicitlyEnabled(value: string | undefined): boolean { return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase()); } +/** + * True when unauthenticated network exposure is acceptable: either a password is + * set (auth active) or the operator explicitly acknowledged it. Used by the + * tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL. + */ +export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean { + if (process.env.CODEMAN_PASSWORD) return true; + return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK); +} + export function isLoopbackBindHost(host: string): boolean { const normalized = host .trim() diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db3e47b5..f36573dc 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -844,11 +844,18 @@ Object.assign(CodemanApp.prototype, { btn.disabled = true; try { const newEnabled = !isActive; - await fetch('/api/settings', { + const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tunnelEnabled: newEnabled }), }); + // COD-55: server refuses an unauthenticated public tunnel (403). Surface it. + if (newEnabled && (await this._handleTunnelEnableRefusal(res))) { + this._dismissTunnelConnecting(); + this._updateWelcomeTunnelBtn(false); + btn.disabled = false; + return; + } if (newEnabled) { this._showTunnelConnecting(); // Poll tunnel status as fallback in case SSE event is missed @@ -1148,13 +1155,40 @@ Object.assign(CodemanApp.prototype, { return `${Math.floor(hrs / 24)}d ago`; }, + /** + * COD-55: detect the server's refusal to start an unauthenticated public tunnel. + * The PUT /api/settings route returns a 4xx with { success:false, error } when no + * CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged. + * Shows the server's (actionable) message as an error toast. + * @param {Response|null} res - the fetch Response from the settings PUT + * @returns {Promise} true if the tunnel-enable was refused (caller should abort) + */ + async _handleTunnelEnableRefusal(res) { + if (!res || res.ok) return false; + let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.'; + try { + const body = await res.json(); + if (body && body.error) message = body.error; + } catch { + /* non-JSON body — use the default message */ + } + this._dismissTunnelConnecting?.(); + this.showToast(message, 'error'); + return true; + }, + async _tunnelPanelToggle(enable) { try { - await fetch('/api/settings', { + const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tunnelEnabled: enable }), }); + // COD-55: server refuses an unauthenticated public tunnel (403). Surface it. + if (enable && (await this._handleTunnelEnableRefusal(res))) { + this.closeTunnelPanel(); + return; + } if (enable) { this._updateTunnelIndicator(false); const indicator = document.getElementById('tunnelIndicator'); @@ -1473,7 +1507,24 @@ Object.assign(CodemanApp.prototype, { // Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings; try { - await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }); + const res = await this._apiPut('/api/settings', { + ...serverSettings, + notificationPreferences: notifPrefsToSave, + voiceSettings, + }); + + // COD-55: the server refuses an unauthenticated public tunnel with a 403 — which + // rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle + // (in the UI + localStorage) so it doesn't look enabled. Other settings persisted + // to localStorage above still apply locally. + if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) { + settings.tunnelEnabled = false; + this.saveAppSettingsToStorage(settings); + const cb = document.getElementById('appSettingsTunnelEnabled'); + if (cb) cb.checked = false; + this.closeAppSettings(); + return; + } // Save model configuration separately await this.saveModelConfigFromSettings(); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index e3bd2f49..0c2f6379 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -14,6 +14,7 @@ import { execSync, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { dataPath } from '../../config/instance.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; +import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; import { ConfigUpdateSchema, SettingsUpdateSchema, @@ -498,6 +499,26 @@ export function registerSystemRoutes( app.put('/api/settings', async (req) => { const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record; + // COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal + // control = effectively RCE) to a public *.trycloudflare.com URL. Because the + // tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and + // with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is + // unauthenticated. Refuse to start a tunnel unless auth is configured OR the + // operator has acknowledged unauthenticated-network exposure. A public tunnel is + // higher-stakes than a LAN bind, so this is REFUSE (vs the bind guard's warn). + // Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved. + if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning() && !isUnauthenticatedNetworkAcknowledged()) { + const msg = + 'Refusing to start the Cloudflare tunnel without authentication: it would publish ' + + 'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' + + 'require login, or set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge an ' + + 'unauthenticated public tunnel.'; + throw Object.assign(new Error(msg), { + statusCode: 403, + body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg), + }); + } + try { const dir = dirname(SETTINGS_PATH); if (!existsSync(dir)) { diff --git a/src/web/server.ts b/src/web/server.ts index 3b91feb7..fa46cc3b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -603,7 +603,7 @@ export class WebServer extends EventEmitter { registerHostGuard(this.app, () => this.getHostPolicy()); // Auth middleware (Basic Auth + session cookies + rate limiting) - const authState = registerAuthMiddleware(this.app, this.https); + const authState = registerAuthMiddleware(this.app, this.https, () => this.tunnelManager.isRunning()); if (authState) { this.authSessions = authState.authSessions; this.authFailures = authState.authFailures; diff --git a/test/cod54-hook-event-auth.test.ts b/test/cod54-hook-event-auth.test.ts new file mode 100644 index 00000000..b3f2ad55 --- /dev/null +++ b/test/cod54-hook-event-auth.test.ts @@ -0,0 +1,150 @@ +/** + * @fileoverview COD-54 — hook-event auth bypass hardening. + * + * The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared + * --url http://127.0.0.1:port) reach the loopback origin with req.ip === + * 127.0.0.1 and drive respawn/Ralph signals unauthenticated. The fix gates + * the bypass behind a shared hook secret WHEN A TUNNEL IS RUNNING, while + * keeping the plain localhost bypass for the normal loopback-only case so + * already-deployed (pre-secret) hooks and the loop's own channel keep working. + * + * Tests: + * - tunnel running + no secret → 401 (closes the hole) + * - tunnel running + bad secret → 401 + * - tunnel running + good secret → not 401 (allowed) + * - tunnel NOT running + no secret → not 401 (back-compat regression guard) + * - rate limiting: rapid unauthorized hook POSTs eventually 429 + * + * Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit) + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { WebServer } from '../src/web/server.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { TunnelManager } from '../src/tunnel-manager.js'; +import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js'; +import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js'; + +const TUNNEL_UP_PORT = 3230; +const TUNNEL_DOWN_PORT = 3231; +const RATE_LIMIT_PORT = 3232; +const TEST_USER = 'admin'; +const TEST_PASS = 'cod54-test-password'; + +vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + +function hookBody(): string { + return JSON.stringify({ event: 'stop', sessionId: 'nonexistent-session', data: {} }); +} + +async function postHook(baseUrl: string, headers: Record = {}): Promise { + return fetch(`${baseUrl}/api/hook-event`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: hookBody(), + }); +} + +describe('COD-54 hook-event auth — tunnel running requires secret', () => { + let server: WebServer; + let baseUrl: string; + let isRunningSpy: ReturnType; + + beforeAll(async () => { + process.env.CODEMAN_PASSWORD = TEST_PASS; + process.env.CODEMAN_USERNAME = TEST_USER; + // Force the middleware's tunnel check to report "running". + isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true); + server = new WebServer(TUNNEL_UP_PORT, false, true); + await server.start(); + baseUrl = `http://localhost:${TUNNEL_UP_PORT}`; + }); + + afterAll(async () => { + await server.stop(); + isRunningSpy.mockRestore(); + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_USERNAME; + }); + + it('rejects a localhost hook POST WITHOUT the secret header (closes the tunnel hole)', async () => { + const res = await postHook(baseUrl); + expect(res.status).toBe(401); + }); + + it('rejects a localhost hook POST with a WRONG secret', async () => { + const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: 'wrong-secret-value' }); + expect(res.status).toBe(401); + }); + + it('allows a localhost hook POST WITH the correct secret', async () => { + const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() }); + // Passes auth (may 200 with success:false for unknown session) — key is NOT 401. + expect(res.status).not.toBe(401); + }); +}); + +describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-compat)', () => { + let server: WebServer; + let baseUrl: string; + let isRunningSpy: ReturnType; + + beforeAll(async () => { + process.env.CODEMAN_PASSWORD = TEST_PASS; + process.env.CODEMAN_USERNAME = TEST_USER; + // Tunnel NOT running — loopback-only normal prod case. + isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false); + server = new WebServer(TUNNEL_DOWN_PORT, false, true); + await server.start(); + baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`; + }); + + afterAll(async () => { + await server.stop(); + isRunningSpy.mockRestore(); + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_USERNAME; + }); + + it('still allows a localhost hook POST WITHOUT a secret (existing hooks + loop channel keep working)', async () => { + const res = await postHook(baseUrl); + expect(res.status).not.toBe(401); + }); +}); + +describe('COD-54 hook-event auth — rate limiting', () => { + let server: WebServer; + let baseUrl: string; + let isRunningSpy: ReturnType; + + beforeAll(async () => { + process.env.CODEMAN_PASSWORD = TEST_PASS; + process.env.CODEMAN_USERNAME = TEST_USER; + // Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted. + isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true); + server = new WebServer(RATE_LIMIT_PORT, false, true); + await server.start(); + baseUrl = `http://localhost:${RATE_LIMIT_PORT}`; + }); + + afterAll(async () => { + await server.stop(); + isRunningSpy.mockRestore(); + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_USERNAME; + }); + + it('eventually returns 429 for rapid unauthorized hook POSTs', async () => { + let saw429 = false; + // A few more than the failure max to cross the threshold. + for (let i = 0; i < AUTH_FAILURE_MAX + 3; i++) { + const res = await postHook(baseUrl); + if (res.status === 429) { + saw429 = true; + expect(res.headers.get('retry-after')).toMatch(/^\d+$/); + break; + } + expect(res.status).toBe(401); + } + expect(saw429).toBe(true); + }); +}); diff --git a/test/routes/system-routes-tunnel-guard.test.ts b/test/routes/system-routes-tunnel-guard.test.ts new file mode 100644 index 00000000..01efdf5e --- /dev/null +++ b/test/routes/system-routes-tunnel-guard.test.ts @@ -0,0 +1,133 @@ +/** + * @fileoverview COD-55 — tunnel password guard. + * + * Enabling the Cloudflare tunnel publishes the whole app (full terminal control = + * effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD + * is set, requests through that URL are unauthenticated. These tests assert the + * PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a + * password is set OR the unauthenticated-network opt-in is acknowledged. + * + * Uses app.inject() — no real HTTP ports needed. Port: N/A. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; + +// Settings are written to disk via fs/promises — stub so the guard test never +// touches the real settings.json, and so we can assert "not persisted on refusal". +vi.mock('node:fs/promises', () => ({ + default: { + readFile: vi.fn(async () => '{}'), + writeFile: vi.fn(async () => undefined), + }, +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), + readdirSync: vi.fn(() => []), + }; +}); + +import fs from 'node:fs/promises'; +const mockedWriteFile = vi.mocked(fs.writeFile); + +/** Build a tunnelManager stub the route's ctx can use. */ +function makeTunnelManager(running = false) { + return { + start: vi.fn(), + stop: vi.fn(), + isRunning: vi.fn(() => running), + getUrl: vi.fn(() => null), + getStatus: vi.fn(() => ({ running })), + }; +} + +describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => { + let harness: RouteTestHarness; + let tunnel: ReturnType; + const savedPassword = process.env.CODEMAN_PASSWORD; + const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + vi.clearAllMocks(); + mockedWriteFile.mockResolvedValue(undefined); + tunnel = makeTunnelManager(false); + // tunnelManager is null in the default mock ctx — inject our spy. + (harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel; + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + }); + + afterEach(async () => { + await harness.app.close(); + if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD; + else process.env.CODEMAN_PASSWORD = savedPassword; + if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn; + }); + + it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { tunnelEnabled: true }, + }); + + expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(res.statusCode).toBeLessThan(500); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + // Message should tell the user how to fix it. + expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/); + // The tunnel must NOT have been started. + expect(tunnel.start).not.toHaveBeenCalled(); + // And tunnelEnabled:true must NOT have been persisted. + expect(mockedWriteFile).not.toHaveBeenCalled(); + }); + + it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => { + process.env.CODEMAN_PASSWORD = 'hunter2'; + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { tunnelEnabled: true }, + }); + + expect(res.statusCode).toBe(200); + expect(tunnel.start).toHaveBeenCalledTimes(1); + }); + + it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1'; + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { tunnelEnabled: true }, + }); + + expect(res.statusCode).toBe(200); + expect(tunnel.start).toHaveBeenCalledTimes(1); + }); + + it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => { + tunnel = makeTunnelManager(true); + (harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel; + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { tunnelEnabled: false }, + }); + + expect(res.statusCode).toBe(200); + expect(tunnel.stop).toHaveBeenCalledTimes(1); + }); +});