Summary
When Claudia is exposed over a tunnel (localtunnel/ngrok), the tunnel auth middleware validates the access token for the root document but lets all /api/* requests through with no token check. Anyone who learns the public tunnel URL can call the REST API unauthenticated. This is pre-existing (shipped since v0.2.30) and was surfaced during the deep review of #75.
Evidence
backend/src/server.ts (tunnel middleware):
- L408–412 — the
/ route requires and validates ?token= via tunnelManager.validateToken.
- L420–421 —
if (req.path.startsWith('/api/')) return next(); → every API route skips the token check over the tunnel.
- L1234–1238 — the WebSocket path does validate the token. So REST and WS are inconsistent.
frontend/src/config/api-config.ts:
getWebSocketUrl() appends ?token= for tunnel access (L58–61), but getApiBaseUrl() returns bare window.location.origin (L38–40) — REST calls carry no token today.
Impact
Why it's not a one-line fix
Adding a server-side token check for /api/* would break the existing frontend/mobile clients, which don't send the token on REST requests. A correct fix is cross-cutting:
- Backend: require + validate the tunnel token for
/api/* (and /api/voice/*) when isTunnelHost(host), accepting it via an Authorization/X-Tunnel-Token header (preferred over a query param to avoid token leakage in logs/referrers).
- Frontend: a centralized authed-fetch wrapper that attaches the token to every API request over the tunnel (today fetch calls are scattered and use
getApiBaseUrl() directly).
- Test end-to-end through an actual tunnel + the mobile flow.
Suggested follow-ups
- Prefer a header-based token over
?token= to reduce leakage; consider short-lived/rotating tokens.
- For
GET /api/voice/deepgram-token, return an ephemeral scoped key (Deepgram Management API) rather than the raw long-lived key.
- Consider basic rate limiting on tunnel-exposed routes.
Severity: high (network-exposed, unauthenticated). Labeling for triage.
Summary
When Claudia is exposed over a tunnel (localtunnel/ngrok), the tunnel auth middleware validates the access token for the root document but lets all
/api/*requests through with no token check. Anyone who learns the public tunnel URL can call the REST API unauthenticated. This is pre-existing (shipped since v0.2.30) and was surfaced during the deep review of #75.Evidence
backend/src/server.ts(tunnel middleware):/route requires and validates?token=viatunnelManager.validateToken.if (req.path.startsWith('/api/')) return next();→ every API route skips the token check over the tunnel.frontend/src/config/api-config.ts:getWebSocketUrl()appends?token=for tunnel access (L58–61), butgetApiBaseUrl()returns barewindow.location.origin(L38–40) — REST calls carry no token today.Impact
/api/*endpoint is reachable unauthenticated over an active tunnel (task data, config, control routes).GET /api/voice/deepgram-tokenraw-key exfiltration). This should be fixed before feat: mobile companion backend (chat, push, summaries) #75 merges. See the review on feat: mobile companion backend (chat, push, summaries) #75 and the related git-injection findings on feat: per-task checkpoints with git snapshot + restore/fork #74.Why it's not a one-line fix
Adding a server-side token check for
/api/*would break the existing frontend/mobile clients, which don't send the token on REST requests. A correct fix is cross-cutting:/api/*(and/api/voice/*) whenisTunnelHost(host), accepting it via anAuthorization/X-Tunnel-Tokenheader (preferred over a query param to avoid token leakage in logs/referrers).getApiBaseUrl()directly).Suggested follow-ups
?token=to reduce leakage; consider short-lived/rotating tokens.GET /api/voice/deepgram-token, return an ephemeral scoped key (Deepgram Management API) rather than the raw long-lived key.Severity: high (network-exposed, unauthenticated). Labeling for triage.