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.
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_TOKENby hand.
Why it happens
cmd/itervox/main.go:674only auto-generates a token when the bind host is non-loopback:And
internal/server/server.go:1201installs the bearer middleware only when a tokenended up non-empty:
So
127.0.0.1bind + no env var →apiToken == ""→ middleware never installed →/api/v1/state,/api/v1/events(SSE), every issue log route, and every mutation routeare 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:127.0.0.1:8090is fine — ngrok talks tolocalhost", then step 4 tells the user to open
?token=<your-token>. On a loopbackbind there is no auto-generated token, so
<your-token>does not exist. If the operatornever set one, the public ngrok URL is wide open. The guide then describes
ITERVOX_API_TOKENas "defence in depth" — but in this configuration it is theonly defence, not an extra layer.
ITERVOX_API_TOKENatall. 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
Expected:
401 unauthorized. Actual: the whole orchestrator state, including issuebodies 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:
http://<addr>/?token=<tok>andAuthGatecaptures?token=on first load and stripsit via
history.replaceState. Local users keep clicking one URL.incidental network detail, which is what the flag was arguably always meant to express.
allow_unauthenticated_lanbecomes the single "I know what I'm doing" switch. It maydeserve renaming (
server.allow_unauthenticated) since it would no longer be LAN-specific.Migration considerations worth checking before doing this:
.itervox/dashboard_url; it would need the token too.itervox doctorand any curl-based local scripting would need to read the token from.itervox/.envor the URL file./api/v1/*unauthenticated would breakon 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
WARNat startup whenever the server binds loopback with no token, stating thatany proxy or tunnel in front of it will be unauthenticated.
Docs fixes (worth doing regardless)
ITERVOX_API_TOKENa required step, not "defence indepth", and show how to generate one.
proxy gets no auth unless the token is set explicitly.
Unrelated docs bug found while verifying this
README.md:282anddocs/configuration.md:381both sayGET /healthis the auth-exemptprobe endpoint. The actual route is
GET /api/v1/health— it is registered insides.router.Route("/api/v1", ...)atinternal/server/server.go:1189. Load-balancer healthchecks configured from the docs will hit the SPA catch-all instead of the probe. Happy to
split this into its own issue if preferred.