mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
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.
68 lines
2.6 KiB
TypeScript
68 lines
2.6 KiB
TypeScript
/**
|
|
* @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;
|
|
}
|