Skip to content

Reverse-tunnel/reverse-proxy deployments run with no auth: token auto-generation keys off bind address, not reachability #48

Description

@vnovick

Summary

The bearer-token auto-generation gate keys off the bind address, not off actual
reachability. Every reverse-tunnel and reverse-proxy deployment — including two options
we ship in our own remote-access guide — puts the dashboard on the public internet with
no authentication at all, unless the operator happens to have set ITERVOX_API_TOKEN
by hand.

Why it happens

cmd/itervox/main.go:674 only auto-generates a token when the bind host is non-loopback:

if host := cfg.Server.Host; host != "127.0.0.1" && host != "localhost" && host != "::1" && host != "" {
    if os.Getenv("ITERVOX_API_TOKEN") == "" {
        ...generate...
    }
}

And internal/server/server.go:1201 installs the bearer middleware only when a token
ended up non-empty:

r.Group(func(r chi.Router) {
    if s.apiToken != "" {
        r.Use(s.bearerAuthMiddleware)
    }
    r.Get("/state", s.handleState)
    r.Get("/events", s.handleEvents)
    ...

So 127.0.0.1 bind + no env var → apiToken == ""middleware never installed
/api/v1/state, /api/v1/events (SSE), every issue log route, and every mutation route
are open to anyone who can reach the socket.

A tunnel or reverse proxy is exactly the thing that makes "anyone" mean "the internet",
and the daemon has no way to see it.

Our own docs walk operators into it

site/src/content/docs/guides/remote-access.mdx:

  • Option 4 (ngrok) — step 2 says "default 127.0.0.1:8090 is fine — ngrok talks to
    localhost"
    , then step 4 tells the user to open ?token=<your-token>. On a loopback
    bind there is no auto-generated token, so <your-token> does not exist. If the operator
    never set one, the public ngrok URL is wide open. The guide then describes
    ITERVOX_API_TOKEN as "defence in depth" — but in this configuration it is the
    only defence, not an extra layer.
  • Option 5 (Piko) — same shape, and it never mentions setting ITERVOX_API_TOKEN at
    all. Step 4 references ?token=<your-token> for a token nothing has created.

The same applies to any cloud VM deployment that terminates TLS in a local reverse proxy
(nginx/Caddy) in front of a loopback bind, which is the standard pattern.

Repro

# WORKFLOW.md: server.host unset (127.0.0.1), server.port: 8090
unset ITERVOX_API_TOKEN
itervox &
ngrok http 8090
curl -s https://<random>.ngrok-free.app/api/v1/state | head   # 200 OK, full state, no auth

Expected: 401 unauthorized. Actual: the whole orchestrator state, including issue
bodies and agent logs.

Suggested fix

The daemon cannot detect a tunnel, so bind-address-based inference is the wrong signal.
Invert the default: always auto-generate a token unless server.allow_unauthenticated_lan: true,
regardless of bind address. Rationale:

  • The UX cost is near zero on loopback. The startup banner already prints
    http://<addr>/?token=<tok> and AuthGate captures ?token= on first load and strips
    it via history.replaceState. Local users keep clicking one URL.
  • It makes the security posture a property of an explicit opt-out rather than of an
    incidental network detail, which is what the flag was arguably always meant to express.
  • allow_unauthenticated_lan becomes the single "I know what I'm doing" switch. It may
    deserve renaming (server.allow_unauthenticated) since it would no longer be LAN-specific.

Migration considerations worth checking before doing this:

  • Vite's dev proxy reads .itervox/dashboard_url; it would need the token too.
  • itervox doctor and any curl-based local scripting would need to read the token from
    .itervox/.env or the URL file.
  • Existing loopback users with automation hitting /api/v1/* unauthenticated would break
    on upgrade — needs a changelog entry and probably a one-release deprecation warning.

Smaller, non-breaking alternative if the above is too aggressive for a patch release: log
a loud WARN at startup whenever the server binds loopback with no token, stating that
any proxy or tunnel in front of it will be unauthenticated.

Docs fixes (worth doing regardless)

  • ngrok + Piko sections: make ITERVOX_API_TOKEN a required step, not "defence in
    depth", and show how to generate one.
  • Add a short "reverse proxy / TLS termination" note stating that a loopback bind behind a
    proxy gets no auth unless the token is set explicitly.

Unrelated docs bug found while verifying this

README.md:282 and docs/configuration.md:381 both say GET /health is the auth-exempt
probe endpoint. The actual route is GET /api/v1/health — it is registered inside
s.router.Route("/api/v1", ...) at internal/server/server.go:1189. Load-balancer health
checks configured from the docs will hit the SPA catch-all instead of the probe. Happy to
split this into its own issue if preferred.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions