mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
Merge pull request #115 from aakhter/pr/cod-78-security
feat(security): hook-event auth secret + tunnel password guard
This commit is contained in:
@@ -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';
|
||||
@@ -28,19 +29,32 @@ interface AuthState {
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||
authFailures: StaleExpirationMap<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
hookSecretFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
qrAuthFailures: null,
|
||||
hookSecretFailures: null,
|
||||
};
|
||||
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
@@ -67,24 +81,70 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
refreshOnGet: false,
|
||||
});
|
||||
|
||||
// Separate hook-secret failure counter (COD-54). MUST NOT share authFailures:
|
||||
// legacy (pre-secret) hook configs fire constantly from 127.0.0.1, and counting
|
||||
// their 401s against the shared bucket would 429 every cookie-less request from
|
||||
// loopback — locking out the Basic-Auth login path (and, through a tunnel, every
|
||||
// client, since tunneled traffic also arrives as 127.0.0.1).
|
||||
state.hookSecretFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
|
||||
const authSessions = state.authSessions;
|
||||
const authFailures = state.authFailures;
|
||||
const hookSecretFailures = state.hookSecretFailures;
|
||||
|
||||
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
|
||||
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
function sendAuthRateLimit(
|
||||
reply: FastifyReply,
|
||||
clientIp: string,
|
||||
failures: StaleExpirationMap<string, number> = authFailures
|
||||
): void {
|
||||
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
}
|
||||
|
||||
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 — rate-limit per IP in the DEDICATED
|
||||
// hook bucket (never authFailures, which would lock out the login path).
|
||||
const hookIp = req.ip;
|
||||
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
|
||||
return;
|
||||
}
|
||||
hookSecretFailures.set(hookIp, hookFailures + 1);
|
||||
reply.code(401).send('Unauthorized: hook secret required');
|
||||
return;
|
||||
}
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<boolean>} 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();
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
// 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)) {
|
||||
|
||||
+12
-1
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
@@ -253,6 +254,7 @@ export class WebServer extends EventEmitter {
|
||||
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
@@ -603,11 +605,12 @@ 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;
|
||||
this.qrAuthFailures = authState.qrAuthFailures;
|
||||
this.hookSecretFailures = authState.hookSecretFailures;
|
||||
}
|
||||
|
||||
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
||||
@@ -1816,6 +1819,10 @@ export class WebServer extends EventEmitter {
|
||||
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
|
||||
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
|
||||
|
||||
// Ensure the COD-54 hook secret exists on disk before any session exports
|
||||
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
|
||||
getHookSecret();
|
||||
|
||||
// Start scheduled runs cleanup timer
|
||||
this.cleanup.setInterval(
|
||||
() => {
|
||||
@@ -2298,6 +2305,10 @@ export class WebServer extends EventEmitter {
|
||||
this.qrAuthFailures.dispose();
|
||||
this.qrAuthFailures = null;
|
||||
}
|
||||
if (this.hookSecretFailures) {
|
||||
this.hookSecretFailures.dispose();
|
||||
this.hookSecretFailures = null;
|
||||
}
|
||||
this.activePlanOrchestrators.clear();
|
||||
this.cleaningUp.clear();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user