Require the hook-event secret unconditionally, not only under a managed tunnel

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).
This commit is contained in:
Aamer Akhter
2026-06-14 12:46:58 -04:00
parent e742d00c98
commit f0f43ddbad
4 changed files with 37 additions and 39 deletions
+14 -26
View File
@@ -36,20 +36,12 @@ interface AuthState {
* Register HTTP Basic Auth middleware with session cookies and rate limiting. * Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set. * Only active when CODEMAN_PASSWORD is set.
* *
* @param getTunnelRunning - returns true while a managed tunnel is active. Used * The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the
* to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled * shared hook secret unconditionally (COD-91) — see the onRequest hook below.
* 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) * @returns AuthState for lifecycle management (dispose on server stop)
*/ */
export function registerAuthMiddleware( export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
app: FastifyInstance,
https: boolean,
getTunnelRunning: () => boolean = () => false
): AuthState {
const state: AuthState = { const state: AuthState = {
authSessions: null, authSessions: null,
authFailures: null, authFailures: null,
@@ -114,30 +106,26 @@ export function registerAuthMiddleware(
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because // 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 // `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 // loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. So: // would pass. COD-91: require the shared hook secret on the loopback bypass
// - tunnel running → bypass requires the shared hook secret (local hooks present // UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
// it via the X-Codeman-Hook-Secret header; internet traffic can't know it), // a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
// - tunnel not running (loopback-only, the normal case) → keep the plain // serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own // bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
// credential-less hook channel keep working. // from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
// always closes the gap without breaking the legitimate hook channel.
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') { if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
const ip = req.ip; const ip = req.ip;
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) { if (isLoopback) {
if (!getTunnelRunning()) { // Always require the shared secret (constant-time compare).
// 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 presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret()); const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) { if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done(); done();
return; return;
} }
// Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED // Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
// hook bucket (never authFailures, which would lock out the login path). // (never authFailures, which would lock out the login path).
const hookIp = req.ip; const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0; const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) { if (hookFailures >= AUTH_FAILURE_MAX) {
+1 -1
View File
@@ -630,7 +630,7 @@ export class WebServer extends EventEmitter {
registerHostGuard(this.app, () => this.getHostPolicy()); registerHostGuard(this.app, () => this.getHostPolicy());
// Auth middleware (Basic Auth + session cookies + rate limiting) // Auth middleware (Basic Auth + session cookies + rate limiting)
const authState = registerAuthMiddleware(this.app, this.https, () => this.tunnelManager.isRunning()); const authState = registerAuthMiddleware(this.app, this.https);
if (authState) { if (authState) {
this.authSessions = authState.authSessions; this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures; this.authFailures = authState.authFailures;
+7 -6
View File
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi }
import { WebServer } from '../src/web/server.js'; import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js'; import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js'; import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
const AUTH_PORT = 3160; const AUTH_PORT = 3160;
const NOAUTH_PORT = 3161; const NOAUTH_PORT = 3161;
@@ -250,28 +251,28 @@ describe('Auth Security', () => {
}); });
describe('Hook Event Endpoint', () => { describe('Hook Event Endpoint', () => {
it('should allow hook events from localhost without auth', async () => { it('should allow hook events from localhost with the hook secret (no Basic auth)', async () => {
const res = await fetch(`${baseUrl}/api/hook-event`, { const res = await fetch(`${baseUrl}/api/hook-event`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
body: JSON.stringify({ body: JSON.stringify({
event: 'stop', event: 'stop',
sessionId: 'nonexistent-session', sessionId: 'nonexistent-session',
data: {}, data: {},
}), }),
}); });
// Should pass auth (localhost bypass) but may 404 on session — that's fine // Should pass auth (localhost bypass + hook secret) but may 404 on session — that's fine.
// The key assertion is it does NOT return 401 // The key assertion is it does NOT return 401 (COD-91: secret required even with no tunnel).
expect(res.status).not.toBe(401); expect(res.status).not.toBe(401);
}); });
it('should reject hook events with invalid schema', async () => { it('should reject hook events with invalid schema', async () => {
const res = await fetch(`${baseUrl}/api/hook-event`, { const res = await fetch(`${baseUrl}/api/hook-event`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
body: JSON.stringify({ invalid: 'data' }), body: JSON.stringify({ invalid: 'data' }),
}); });
// Schema validation should catch this // Past the auth gate (valid secret) → schema validation should catch this (not a 401).
expect(res.status).not.toBe(401); // Not an auth error expect(res.status).not.toBe(401); // Not an auth error
}); });
}); });
+15 -6
View File
@@ -4,15 +4,19 @@
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared * The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
* --url http://127.0.0.1:port) reach the loopback origin with req.ip === * --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 * 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 * the bypass behind a shared hook secret. COD-91 makes that requirement
* keeping the plain localhost bypass for the normal loopback-only case so * UNCONDITIONAL — the loopback bypass requires the secret whether or not a
* already-deployed (pre-secret) hooks and the loop's own channel keep working. * managed tunnel is running, because Codeman can't detect a user's own loopback
* reverse proxy (own cloudflared / `tailscale serve` / nginx → 127.0.0.1).
* Managed-session hooks always present the secret, so the legitimate channel
* keeps working.
* *
* Tests: * Tests:
* - tunnel running + no secret → 401 (closes the hole) * - tunnel running + no secret → 401 (closes the hole)
* - tunnel running + bad secret → 401 * - tunnel running + bad secret → 401
* - tunnel running + good secret → not 401 (allowed) * - tunnel running + good secret → not 401 (allowed)
* - tunnel NOT running + no secret → not 401 (back-compat regression guard) * - tunnel NOT running + no secret → 401 (COD-91: secret required unconditionally)
* - tunnel NOT running + good secret → not 401 (allowed)
* - rate limiting: rapid unauthorized hook POSTs eventually 429 * - rate limiting: rapid unauthorized hook POSTs eventually 429
* *
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit) * Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
@@ -83,7 +87,7 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => {
}); });
}); });
describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-compat)', () => { describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () => {
let server: WebServer; let server: WebServer;
let baseUrl: string; let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>; let isRunningSpy: ReturnType<typeof vi.spyOn>;
@@ -105,8 +109,13 @@ describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-co
delete process.env.CODEMAN_USERNAME; delete process.env.CODEMAN_USERNAME;
}); });
it('still allows a localhost hook POST WITHOUT a secret (existing hooks + loop channel keep working)', async () => { it('rejects a localhost hook POST WITHOUT a secret even with no tunnel (COD-91)', async () => {
const res = await postHook(baseUrl); const res = await postHook(baseUrl);
expect(res.status).toBe(401);
});
it('allows a localhost hook POST WITH the correct secret when no tunnel is running', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
expect(res.status).not.toBe(401); expect(res.status).not.toBe(401);
}); });
}); });