# Public Navi Frontend with Selective Backend Auth
**Status:** Design — implementation deferred to dedicated Phase 3 module
**Owner:** Matt
**Last updated:** 2026-04-25
---
## 1. Principle
A request to `navi.echo6.co` triggers an **external paid API call** (Google Places, TomTom, Gemini, or any future paid integration) **ONLY IF** the request carries a valid Authentik session. No session = no external call, ever. This is a hard gate, not a soft preference.
Concretely:
- **Local stack services** (Photon, Nominatim, Valhalla, Overture Postgres, PAD-US, OSM wiki rewriter, contour/hillshade/public-lands tiles) can serve unauthenticated users, subject to rate limiting.
- **External paid services** (Google Places, TomTom, Gemini, anything that costs real money per call) are authenticated-only, no exceptions, no fallbacks, no "low-cost" carve-outs.
- **User-state-tied features** (contacts, address book, anything keyed to `X-Authentik-Username`) are authenticated-only.
- **Backend administration** (API key management, RECON pipeline controls, settings, scrapers) lives behind `recon.echo6.co` and is fully Authentik-gated. Never exposed via `navi.echo6.co`.
The migration is gated on **verified protection of paid APIs**, not just architecture that's "designed to gate."
---
## 2. Two Frontends, Two Auth Postures
| Domain | Posture | Scope |
|---|---|---|
| `navi.echo6.co` | Public + selective backend auth | End-user navigation. Browse map, search, route, view places. Authenticated for personal data + paid enrichment. **Migration target.** |
| `recon.echo6.co` | Fully Authentik-gated, admin-only | Backend administration: API keys, RECON dashboard, Nav-I dashboard, settings, scrapers, pipeline controls. **Unchanged from today, NOT part of this migration.** |
**Hard separation:** No path on `navi.echo6.co` reaches admin endpoints. No `/api/keys`, `/api/settings`, or backend configuration is exposed via Navi's reverse proxy. The two domains share a RECON backend but expose different surface areas.
---
## 3. Endpoint Inventory and Classification
Four categories:
- **PUBLIC** — local stack data, no external calls, no per-user state. Public access with rate limiting.
- **AUTHED-EXTERNAL** — exists solely to proxy a paid external API. Caddy forward-auth gated. No unauthenticated fallback.
| `/api/keys/*` | NOT EXPOSED via navi | Lives on recon.echo6.co only |
| `/api/settings/*` | NOT EXPOSED via navi | Lives on recon.echo6.co only |
**Verification step before migration:** audit RECON's actual route definitions to confirm the mapping is complete. Routes not in this table must be classified before `navi.echo6.co` is opened.
---
## 4. Defense in Two Layers
External-API protection cannot rely on a single layer of config. Two independent gates:
### Layer 1 — Caddy
`navi.echo6.co`'s vhost on CT 101 applies forward-auth selectively:
- **Public paths:** no auth, rate-limited (see §6)
- **AUTHED-EXTERNAL paths:** forward-auth required. Unauthenticated requests rejected at Caddy with 401. Never reach RECON.
`recon.echo6.co`'s vhost remains fully forward-auth gated as today. Unchanged.
### Layer 2 — RECON Backend
Mixed routes (and any other route that conditionally calls external APIs) MUST validate authentication independently before triggering paid calls. The pattern:
if auth_user and is_valid_authentik_user(auth_user):
google_data = query_google_places(place_id)
data.update(google_data)
# No valid auth = no Google call. Period. No fallback. No exceptions.
return data
```
The `is_valid_authentik_user()` check is non-trivial. Header presence alone is insufficient if Caddy isn't stripping client-supplied headers (see §5).
---
## 5. Header Spoofing Defense
Caddy must strip incoming `X-Authentik-Username` headers from client requests, then inject the validated username after forward-auth succeeds. Otherwise, an attacker can set their own header and bypass the auth gate.
**Required Caddy directive on every reverse_proxy block exposed to clients:**
```caddyfile
reverse_proxy {
header_up -X-Authentik-Username
# forward_auth then injects the validated header for authed requests
}
```
**Pre-migration audit:** verify this directive exists on `recon.echo6.co`'s current vhost. If absent, that's a pre-existing security issue regardless of this migration. Fix before opening `navi.echo6.co` to public traffic.
| Unauthenticated requests (no valid Authentik session) | 60 req/min per IP, burst 30, 429 + Retry-After on exceeded |
| Authenticated requests (valid Authentik session) | No limit |
Rationale:
- Protects public surface from abuse and casual scraping
- Never gets in the way of the actual user (Matt) or any future authenticated users
- Single-user system today; tiered model remains correct under multi-user expansion
Implementation: Caddy matcher applies `rate_limit` only to requests lacking the validated auth header. Authed requests bypass the limiter entirely.
---
## 7. Mixed Route Behavior — `/api/place`
Single mixed route in current scope. Behavior:
- **Unauthenticated:** returns Overture + OSM + Nominatim reverse + PAD-US data. Google Places not called. Response is complete and useful — the panel renders, just without Google's enrichment.
- **Authenticated:** all of the above, plus Google Places enrichment merged into the response.
Frontend should not render visibly different UI for the two cases. Google Places fills gaps (phone, hours, ratings); when those gaps exist in local data they show as missing. No "log in to see more" prompt embedded in the panel — that adds friction and reveals which fields come from where.
If a future feature needs to be visibly authed-only (e.g., "save this place to contacts"), it gets its own UI affordance gated on auth state, separate from the place detail data.
---
## 8. Frontend 401 UX (Open Question)
When a user without a session triggers an authenticated route (clicking "save contact," opening Aurora chat, etc.), three patterns to consider:
| Pattern | Pros | Cons |
|---|---|---|
| **Inline auth modal** | Smooth UX. Returns to current map state after auth. | Most implementation work. |
| **Redirect to Authentik, then back** | Simple. Reuses existing flow. | Loses map state (zoom, pan, panel state). |
| **Visual gating** (gray out authed features pre-emptively) | No surprises. Clear what requires auth. | Reveals auth requirements visibly to all users; may feel restrictive. |
**Recommendation:** redirect-with-return-URL for rare actions (save contact, open Aurora). Visual gating for areas that don't make sense without state (contacts panel shows "log in to view your contacts" empty state).
**Decision deferred to implementation session.**
---
## 9. CDN / Edge Concerns (Open Question)
Cloudflare or similar in front of `navi.echo6.co` provides:
- DDoS protection
- Bot mitigation beyond simple rate limiting
- Geographic edge caching for static tiles (faster TTFB globally)
- TLS termination handoff
**Not in scope for initial migration.** Add after public exposure if abuse patterns emerge, or as a Phase 3 hardening pass.
If added later, the rate limiting (§6) and header-spoofing defense (§5) need to account for Cloudflare's `CF-Connecting-IP` header for accurate per-IP limiting.
---
## 10. Pre-Migration Diagnostics
These must complete before any Caddy public-exception change is deployed:
Per Matt's note: TomTom traffic overlay is not rendering despite `has_traffic_overlay: true`, an active API key, and an expected periodic auto-pull mechanism.
**Required outcome:** confirm whether TomTom is callable from unauthenticated requests in the current setup. If yes, this is a money-cost vector that must be closed before public exposure.
**Diagnostic scope** (separate task, see [TomTom diagnostic prompt]):
- Verify API key presence and validity
- Identify the auto-pull mechanism (cron/timer/in-process scheduler)
**CRITICAL:** RECON backends (ports 8420, 8440) bound to `0.0.0.0` with no firewall rules, accepting the `X-Authentik-Username` header unconditionally. Any host on the LAN (192.168.1.0/24) or Tailscale network (100.64.0.0/10) could bypass Caddy entirely, connect directly to backends, and spoof any user identity via HTTP header injection.
**Verified exploitation:** From cortex (100.64.0.14), we successfully read all contacts, added/removed API keys, and accessed admin functions using a single spoofed header.
Caddy itself was NOT vulnerable—the `forward_auth` directive properly authenticates and `copy_headers` overwrites client-supplied headers. The vulnerability was **unprotected direct backend access**.
#### Mitigation Summary
Applied iptables firewall rules on VM 1130 (RECON host) restricting ports 8420 and 8440 to three allowed sources:
1.**localhost** (lo interface) — for internal health checks and local tooling
2.**CT 101 LAN IP** (192.168.1.101) — Caddy reverse proxy
**Critical implementation note:** On hosts running Tailscale, the `ts-input` chain is jumped to early in the INPUT chain and accepts all traffic from the `tailscale0` interface before any subsequent rules are evaluated.
```
Chain INPUT (policy ACCEPT)
num target prot source destination
1 ts-input all 0.0.0.0/0 0.0.0.0/0 ← Tailscale jumps here first
...
```
Inside `ts-input`, rule 4 is typically `ACCEPT all -- tailscale0 * 0.0.0.0/0 0.0.0.0/0`, which accepts ALL Tailscale traffic unconditionally.
**Consequence:** Any port-specific DROP rules appended to INPUT will never be reached for Tailscale traffic. Custom firewall rules on Tailscale hosts **must be inserted at position 1** (before the ts-input jump) to take effect.
```bash
# WRONG — will never block Tailscale traffic:
iptables -A INPUT -p tcp --dport 8420 -j DROP
# CORRECT — inserted before ts-input:
iptables -I INPUT 1 -p tcp --dport 8420 -j DROP
```
#### Final Rule Set
Rules applied and persisted via `netfilter-persistent`:
Audit `/opt/recon/lib/api.py` (and any other route definitions) to confirm:
- Every route is classified per §3
- No admin routes are reachable through `navi.echo6.co`'s expected reverse-proxy path
- Every route that calls a paid external service has a backend-layer auth check (§4 Layer 2)
If any route fails (3) — calls a paid API without checking auth — that's the highest-priority fix before migration.
### 10.4 — Background-job and integration audit
Identify anything that hits RECON via Caddy that might break when auth posture changes:
- Cron jobs hitting `/api/*` from outside the Proxmox cluster
- External integrations expecting public access
- Mobile PWA sessions (likely fine — Authentik sessions persist through Caddy reload)
-`recon-watchdog` (already bypasses Caddy via localhost:8420 — unaffected)
Most of Matt's stack is internal and unaffected. Audit confirms this before flipping config.
---
## 11. Migration Plan
**Each phase is independently deployable and rollback-able.**
### Phase 1 — Caddy public exception for static frontend
- Update `navi.echo6.co` vhost on CT 101: public access for `/`, `/tiles/*`, `/assets/*`
- All `/api/*` paths remain forward-auth gated for now (no behavior change yet)
- Verify `header_up -X-Authentik-Username` is on the reverse_proxy block
- Test: unauthenticated browser loads the map and renders tiles. API calls still 401.
**Rollback:** revert Caddyfile, reload Caddy.
### Phase 2 — Backend auth posture changes
- For each PUBLIC route in §3: confirm the RECON handler does not require `X-Authentik-Username` (or makes it optional). No code changes needed if handlers don't currently check.
- For each AUTHED-EXTERNAL route: confirm the RECON handler validates auth before calling external API.
- For MIXED routes (`/api/place`): implement the §4 Layer 2 pattern.
- Add `is_valid_authentik_user()` helper if not present.
- Update `navi.echo6.co` vhost: PUBLIC routes (per §3) become unauthenticated-accessible.
- AUTHED-EXTERNAL and AUTHED-USER routes retain forward-auth.
- MIXED routes: no Caddy gate (backend handles).
- Test matrix: unauthenticated request to each PUBLIC route → 200. Unauthenticated request to each AUTHED route → 401. Authenticated request to each → 200.
**Rollback:** revert Caddyfile, reload Caddy.
### Phase 4 — Tiered rate limiting
- Add Caddy `rate_limit` matcher for unauthenticated requests on PUBLIC routes.
- 60 req/min per IP, burst 30.
- Authenticated requests bypass entirely.
- Test: 70 unauthenticated requests in 60s → 429s after 60. Authenticated client unaffected.
- Logging: per-request auth state, route, response time
- Metrics: 429 rate, 401 rate, top public routes by request volume
- Alerting: spike in unauthenticated traffic, repeated 429s from single IP
- Dashboards in Nav-I admin panel
**Rollback:** N/A — additive only.
### Acceptance criteria for declaring migration complete
Migration is "done" when ALL of the following are verified:
1. Unauthenticated request to every AUTHED-EXTERNAL route → 401 from Caddy, no backend processing
2. Unauthenticated request to `/api/place` for a known place → returns local data only, NO Google Places API call observed in logs
3. Unauthenticated request to `/api/traffic/*` → 401
4. Unauthenticated request to `/api/aurora/*` → 401
5. Header-spoofing test: unauthenticated request with hand-crafted `X-Authentik-Username: matt` header → still 401 (Caddy strips it before forward_auth)
6. Authenticated request through every route works as before
7. Rate limit triggers for unauthenticated client, never for authenticated
8.`recon.echo6.co` is unchanged and remains fully gated
If any of these fails, migration is not done. Roll back the failing phase, fix, re-verify.
---
## 12. Resolved Decisions
| Question | Decision |
|---|---|
| Admin role distinction | Two separate frontends. `navi.echo6.co` has no admin surface. `recon.echo6.co` is admin-only and unchanged. |
| Upload size limits | N/A — no upload endpoints in current scope. Future concern. |
| In-progress operations during migration | Authentik sessions persist through Caddy reload. RECON background jobs are internal and unaffected. recon-watchdog already bypasses Caddy. Pre-migration audit (§10.4) confirms no external integrations break. |
| Mixed route handling | `/api/place` returns local data unauthenticated, adds Google Places enrichment when authed. Backend-layer gate (§4 Layer 2). |
| TomTom cost model | Free tier 2.5k req/day. Authed-only regardless. Currently non-functional — diagnose before migration (§10.1). |
## 13. Open Questions
| Question | Owner | Decision needed by |
|---|---|---|
| Frontend 401 UX pattern (modal vs redirect vs visual gating) | Matt | Phase 5 implementation |
| CDN/Cloudflare in front of navi.echo6.co | Matt | Optional, post-migration hardening |
| `/api/config` behavior — suppress authed-only feature flags for unauth callers, or return all and let frontend handle? | Matt | Phase 2 implementation |
| TomTom traffic overlay — non-functional, root cause unknown | Diagnostic task | Before Phase 1 |
| CT 101 Caddy config — not yet read | SSH-jump task | Before Phase 1 |
---
## 14. Out of Scope
- Multi-user expansion (current system is single-user; design supports multi-user future without rework)
**CRITICAL FINDING:** All RECON backends (8420, 8440, 8430, 8888) listen on 0.0.0.0 and accept connections from any LAN/Tailscale host. The `X-Authentik-Username` header is trusted without validation. An attacker on the LAN or Tailscale network can bypass Caddy entirely, hit backends directly, and spoof any user identity.
**Caddy itself is NOT vulnerable** to header spoofing. The `forward_auth` directive properly authenticates users via Authentik, and the `copy_headers` directive overwrites any client-supplied headers with Authentik's response. The vulnerability is the **unprotected direct backend access**.
None of the Caddy vhosts include `header_up -X-Authentik-Username` or similar directives to strip incoming client headers. However, this is **not the root cause** of the vulnerability:
1.**Through Caddy:** The `forward_auth` directive validates the session with Authentik. The `copy_headers` directive copies Authentik's response headers to the upstream request, which **overwrites** any client-supplied headers. Header spoofing through Caddy does NOT work.
2.**Direct backend access:** The backend is reachable without going through Caddy at all. No header stripping in Caddy helps here — the request never touches Caddy.
The RECON backend trusts `X-Authentik-Username` header unconditionally, assuming all requests come through Caddy's forward_auth. This assumption fails when: