feat(security): hook-event auth secret + tunnel password guard#115
Conversation
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 (Ark0N#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.
…mit bucket Review fixes for COD-54: - Generated hook curl commands now present X-Codeman-Hook-Secret, read from the secret file AT EXECUTION TIME via $CODEMAN_HOOK_SECRET_FILE (exported into every managed session's env by tmux buildEnvExports / the direct-PTY env builders). Without this, every local hook 401'd the moment a managed tunnel came up — the enforcement existed but nothing presented the secret. Path-not-value keeps the secret off command lines and out of config files, and running sessions pick up a newly generated secret with no respawn; server.start() ensures the file exists up front. - Hook-secret failures now count into a DEDICATED per-IP bucket (hookSecretFailures) instead of the shared authFailures map. Legacy (pre-secret) hook configs fire constantly from 127.0.0.1; counting their 401s against the shared bucket would 429 every cookie-less loopback request — locking out the Basic-Auth login path (and, through a tunnel, every client, since tunneled traffic also arrives as 127.0.0.1). - docs/security-architecture.md: secret-gated hook exemption, dedicated bucket, COD-55 refusal, and the residual caveat for EXTERNAL loopback proxies (user-run cloudflared / tailscale serve), which the managed-tunnel probe cannot see. - test/cod54-hook-event-auth.test.ts: +3 tests — login path unaffected after hook-bucket exhaustion; generated hooks reference the header + $CODEMAN_HOOK_SECRET_FILE without embedding the value; env builders export the path only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ark0N
left a comment
There was a problem hiding this comment.
Reviewed. The design is right (secret-gated loopback bypass while the managed tunnel runs; refuse-to-start for an unauthenticated tunnel), the COD-55 half needed no changes, but COD-54 had two blockers — both fixed in aa4e1ce pushed to this branch:
1. Nothing actually presented the secret. The middleware enforcement landed, but hooks-config.ts — which generates the hook curl commands — was untouched, so no hook in existence sent X-Codeman-Hook-Secret. The moment a managed tunnel came up (with CODEMAN_PASSWORD set — the recommended tunnel setup), every local hook (stop, idle_prompt, permission_prompt, …) started 401ing, silently degrading idle detection and respawn. Fixed: hook curls now send the header, reading the secret file at execution time via $CODEMAN_HOOK_SECRET_FILE (exported into every managed session's env — tmux buildEnvExports + both direct-PTY env builders). Path-not-value keeps the secret off command lines (per the existing tmux setenv discipline) and out of case configs, and running sessions pick up a newly generated secret without respawn. server.start() ensures the file exists up front.
2. Hook 401s burned the shared per-IP failure budget. Failed secret checks incremented authFailures['127.0.0.1'] — the same bucket the Basic-Auth path consults. Legacy (pre-secret) hook configs fire constantly, so within a few turns the bucket hits AUTH_FAILURE_MAX and every cookie-less loopback request 429s before the WWW-Authenticate challenge — locking out browser login entirely (and, through a tunnel, every client, since tunneled traffic also arrives as 127.0.0.1), continuously re-armed while a session runs. Fixed: dedicated hookSecretFailures bucket (mirrors qrAuthFailures), disposed in server.stop(). Added a regression test that exhausts the hook bucket and asserts the login path still answers 401→200.
Also: docs/security-architecture.md documented the old hole verbatim ("it does not gate the hook-event exemption") — updated for the new model, including the honest residual caveat: the gate keys off the managed tunnel, so an externally run loopback proxy (own cloudflared, tailscale serve) still gets the plain bypass. Since regenerated hook configs now always present the header, a future release can require the secret unconditionally.
Verified in a clean worktree: tsc, eslint, prettier --check, frontend-syntax all clean; full test:ci green (2726 passed, +3 new tests).
Two hardening fixes for the public-tunnel exposure path.
COD-54 — gate the
/api/hook-eventlocalhost bypass when a tunnel is upcloudflared --url http://127.0.0.1:portproxies internet traffic into the loopback origin, so a tunneled hook request arrives withreq.ip === 127.0.0.1— the old bare-localhost bypass would accept it unauthenticated. Now:X-Codeman-Hook-Secretheader, constant-time compare) + per-IP rate limiting on failures;New
src/config/hook-secret.ts; the auth middleware takes agetTunnelRunningprobe, wired fromserver.tsviatunnelManager.isRunning().COD-55 — refuse starting the Cloudflare tunnel without auth
Enabling the tunnel publishes full terminal control to a public URL; with no
CODEMAN_PASSWORDthe auth middleware is inactive and the non-loopback bind guard never trips (the tunnel binds loopback).PUT /api/settingsnow refusestunnelEnabled: truewith a 403 before persisting, unlessCODEMAN_PASSWORDis set orCODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1is acknowledged. NewisUnauthenticatedNetworkAcknowledged()innetwork-auth-policy; the settings UI surfaces the refusal as an error toast and reverts the toggle. (A public tunnel is higher-stakes than a LAN bind, so this is refuse vs the bind guard's warn.)Scope
The always-on CSRF/Origin guard, Host-header allowlist, and
network-auth-policyitself are already upstream (#113) and not re-proposed here.Verification
tsc,eslint,prettier --check,check:frontend-syntaxclean; fulltest:cigreen (2723 passed), including newtest/cod54-hook-event-auth.test.tsandtest/routes/system-routes-tunnel-guard.test.ts.