Skip to content

fix(security): remove loopback-trust auth bypass#77

Merged
steve-krisjanovs merged 1 commit into
mainfrom
fix/loopback-auth-bypass
Jul 1, 2026
Merged

fix(security): remove loopback-trust auth bypass#77
steve-krisjanovs merged 1 commit into
mainfrom
fix/loopback-auth-bypass

Conversation

@steve-krisjanovs

Copy link
Copy Markdown
Contributor

Summary

Security fix. isAuthorized()/isWsAuthorized() in daemon/web/server.ts treated any request whose remoteAddress looked like loopback (127.0.0.1/::1) as fully authenticated — meant as a convenience for local CLI calls. But tailscale serve, llmux's own documented remote-access path, proxies connections to the daemon locally, so every tailnet visitor's request arrives looking exactly like a trusted local one.

Confirmed live and exploitable against the running daemon's own Tailscale-fronted URL, zero credentials:

  • GET /api/sessions → full session list (cwd, env, initPrompts)
  • GET /api/settings → the entire daemon config yaml
  • POST /api/sessions (spawn) and the WS attach route sit behind the identical bypass (code-path confirmed, not live-fired to avoid side effects)

Fix

Drop the remoteAddress special-case entirely. Auth is now always a real v1 SAS token or v2 session/bearer — exactly like /account and /admin/users already required. There's no way to make an IP-based "is this really local" check safe once anything can proxy over loopback, so the fix is to stop trusting address at all.

Default local CLI usage is unaffected — it calls daemon handlers in-process directly (no HTTP round-trip), so it never went through this gate either way.

Also fixed a latent self-lockout: the HTML-route failure path served a v1-only SAS-token-entry form (gatePage()). Since v1 tokens can no longer be minted (unified to v2 in 0.37.0), an admin with only a v2 account — the normal case now — had no way through it. Redirects to /login?returnTo=<path> instead, mirroring v2-routes.ts's existing pattern. gatePage() itself stays (still serves the legacy ?token= deep-link); its only other caller, sendGate(), is removed as dead code.

Test plan

  • npm run typecheck clean
  • npm run build clean
  • GET / unauthenticated → 302 /login?returnTo=%2F (was 200, full page)
  • GET /api/sessions, /api/settings, /api/logs, /api/agents unauthenticated → 401 (was 200 with real data)
  • /account, /admin/users, /login unaffected (still 302/200 as before)
  • /health, /api/version (deliberately public) unaffected
  • With a freshly-minted v2 bearer token → all routes 200 with correct data again — legitimate access unbroken

🤖 Generated with Claude Code

https://claude.ai/code/session_01Au8T9RPCb6wbgiEecQrBfZ

isAuthorized()/isWsAuthorized() short-circuited to true for any request
whose req.socket.remoteAddress looked like 127.0.0.1/::1 — meant to let
local CLI calls skip auth. But tailscale serve (llmux's own documented
remote-access path) proxies connections locally, so every tailnet
visitor's request arrives at the Node process looking exactly like a
trusted local one. Confirmed live and exploitable: unauthenticated
GET /api/sessions (full cwd/env/initPrompts) and GET /api/settings (the
entire daemon config yaml) returned real data over the daemon's own
Tailscale-fronted URL with zero credentials. POST /api/sessions (spawn a
session) and the WS attach route sat behind the identical bypass.

Fix: drop the remoteAddress special-case entirely. Auth is now always a
real v1 SAS token or v2 session/bearer, exactly like the v2 routes
(/account, /admin/users) already required — an IP-based local check
cannot be made safe once anything can proxy to the daemon over loopback,
so the fix is to stop trusting address at all rather than trying to
detect the proxy case.

Default local CLI usage (no --server flag) is unaffected — per the auth
recon, it calls daemon handlers in-process directly and never makes an
HTTP request, so it never went through this gate either way.

Also fixed a latent self-lockout: the HTML-route failure path served
gatePage(), a v1-only SAS-token-entry form. Since v1 tokens can no longer
be minted (tokens unified to v2 in 0.37.0), an admin with only a v2
account (the normal case now) had no way through that form at all.
Redirect to /login?returnTo=<path> instead, the same mechanism
v2-routes.ts already uses for /account and /admin/users. gatePage()
itself is kept (still used by the legacy ?token= deep-link for anyone
who still holds an old v1 SAS token); sendGate(), its only other caller,
is removed as dead code.

Verified end-to-end against the running daemon: GET / now 302s to
/login?returnTo=%2F instead of 200; GET /api/sessions, /api/settings,
/api/logs, /api/agents now 401 instead of 200 with real data; /account,
/admin/users, /login unaffected; /health, /api/version (deliberately
public) unaffected. With a freshly-minted v2 bearer token, all of the
above return 200 with correct data again — legitimate access is not
broken, only the unauthenticated bypass is closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Au8T9RPCb6wbgiEecQrBfZ
@steve-krisjanovs steve-krisjanovs merged commit e0b729e into main Jul 1, 2026
1 check passed
@steve-krisjanovs steve-krisjanovs deleted the fix/loopback-auth-bypass branch July 1, 2026 14:33
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.

1 participant