Skip to content

feat(security): hook-event auth secret + tunnel password guard#115

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
aakhter:pr/cod-78-security
Jun 10, 2026
Merged

feat(security): hook-event auth secret + tunnel password guard#115
Ark0N merged 2 commits into
Ark0N:masterfrom
aakhter:pr/cod-78-security

Conversation

@aakhter

@aakhter aakhter commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Two hardening fixes for the public-tunnel exposure path.

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 — the old bare-localhost bypass would accept it unauthenticated. Now:

  • tunnel running → the bypass requires a shared per-instance hook secret (X-Codeman-Hook-Secret header, constant-time compare) + per-IP rate limiting on failures;
  • tunnel not running (loopback-only, the normal case) → unchanged, so already-deployed credential-less local hooks keep working.

New src/config/hook-secret.ts; the 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 non-loopback bind guard never trips (the 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; 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-policy itself are already upstream (#113) and not re-proposed here.

Verification

tsc, eslint, prettier --check, check:frontend-syntax clean; full test:ci green (2723 passed), including new test/cod54-hook-event-auth.test.ts and test/routes/system-routes-tunnel-guard.test.ts.

aakhter and others added 2 commits June 10, 2026 12:25
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 Ark0N left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@Ark0N Ark0N merged commit f0db5f8 into Ark0N:master Jun 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants