refactored-recon/AUTH-PUBLIC-FRONTEND.md

630 lines
30 KiB
Markdown
Raw Permalink Normal View History

# 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.
- **AUTHED-USER** — per-user state. Caddy forward-auth gated.
- **MIXED** — returns local data freely; optional enrichment from paid API only when authenticated.
### Classified routes
| Route | Category | Notes |
|---|---|---|
| `/` (frontend HTML/JS/CSS/assets) | PUBLIC | Static frontend |
| `/tiles/*` (PMTiles archives) | PUBLIC | Basemap, hillshade, contours, public lands |
| `/api/photon/*` | PUBLIC | Local geocoder |
| `/api/nominatim/*` | PUBLIC | Local reverse geocoder |
| `/api/valhalla/*` | PUBLIC | Local routing |
| `/api/overture/*` | PUBLIC | Local POI Postgres |
| `/api/padus/*` | PUBLIC | Local public lands data |
| `/api/wiki/*` | PUBLIC | Local cache + OSM wiki rewriter |
| `/api/kiwix/*` | PUBLIC | Local ZIM archives (when wired) |
| `/api/place` | **MIXED** | Local data unauthenticated; Google Places enrichment authed-only |
| `/api/config` | PUBLIC | Feature flags. May suppress authed-only flags for unauthenticated callers (TBD) |
| `/api/traffic/*` | AUTHED-EXTERNAL | TomTom proxy. No local fallback. |
| `/api/aurora/*` | AUTHED-EXTERNAL | Treat as paid: compute-expensive, GPU-bound |
| `/api/gemini/*` (if exposed) | AUTHED-EXTERNAL | Paid API |
| `/api/contacts/*` | AUTHED-USER | Per-user via `X-Authentik-Username` |
| `/api/address-book/*` | AUTHED-USER | Per-user |
| `/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.
- **AUTHED-USER paths:** forward-auth required. Same.
- **MIXED paths (`/api/place`):** no Caddy gate; backend handles enrichment decision.
`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:
```python
def get_place(place_id):
# Always-available local data
data = query_overture(place_id)
data.update(query_osm(place_id))
data.update(query_nominatim_reverse(...))
# External paid API — HARD GATE on verified auth
auth_user = request.headers.get('X-Authentik-Username')
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.
---
## 6. Rate Limiting
**Tiered: unauthenticated rate-limited, authenticated unlimited.**
| Audience | Rate Limit |
|---|---|
| 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:
### 10.1 — TomTom traffic overlay (currently non-functional)
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)
- Determine why pulls aren't happening (or aren't reaching frontend)
- Confirm the route's auth posture before fixing the rendering bug
### 10.2 — CT 101 Caddy configuration audit
SSH access to CT 101 from Matt's workstation requires jump through cortex (192.168.1.150). Once accessible:
- Read full Caddy config for `navi.echo6.co` and `recon.echo6.co`
- Confirm `header_up -X-Authentik-Username` is present on existing reverse_proxy blocks
- Document the current vhost structure to inform the migration patch
- Append findings to this doc as §10.2 appendix
### 10.2.1 — P0 Mitigation Applied (2026-04-25)
#### Vulnerability Description
**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
3. **CT 101 Tailscale IP** (100.64.0.8) — Caddy reverse proxy via Tailscale
All other traffic to these ports is dropped.
#### Tailscale iptables Ordering Requirement
**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`:
```
Chain INPUT (policy ACCEPT)
num target prot in source dport comment
1 ACCEPT tcp lo 0.0.0.0/0 8420 P0 auth spoofing fix 2026-04-25
2 ACCEPT tcp * 192.168.1.101 8420 P0 auth spoofing fix 2026-04-25
3 ACCEPT tcp * 100.64.0.8 8420 P0 auth spoofing fix 2026-04-25
4 DROP tcp * 0.0.0.0/0 8420 P0 auth spoofing fix 2026-04-25
5 ACCEPT tcp lo 0.0.0.0/0 8440 P0 auth spoofing fix 2026-04-25
6 ACCEPT tcp * 192.168.1.101 8440 P0 auth spoofing fix 2026-04-25
7 ACCEPT tcp * 100.64.0.8 8440 P0 auth spoofing fix 2026-04-25
8 DROP tcp * 0.0.0.0/0 8440 P0 auth spoofing fix 2026-04-25
9 ts-input all * 0.0.0.0/0 * (Tailscale chain)
```
#### Verification Results
| Test | Expected | Actual |
|------|----------|--------|
| Direct 8420 from cortex (100.64.0.14) | BLOCKED | BLOCKED (8 pkts dropped) |
| Direct 8440 from cortex (100.64.0.14) | BLOCKED | BLOCKED (5 pkts dropped) |
| Direct 8420 from TOC (100.64.0.5) | BLOCKED | BLOCKED |
| Via Caddy (CT 101) → 8440 | ALLOWED | ALLOWED (4 pkts accepted) |
| Localhost → 8420 (health check) | ALLOWED | ALLOWED |
#### Outstanding Items (Broader Auth Migration)
The following remain part of the scheduled migration work and are **not addressed by this P0 fix**:
1. **`header_up -X-Authentik-Username` on Caddy reverse_proxy blocks**
- Defense-in-depth measure to strip client-supplied auth headers before forwarding
- Scheduled for migration session (Phase 3)
- Not urgent: Caddy's `copy_headers` already overwrites these headers with Authentik's response
2. **Other 0.0.0.0-bound services on VM 1130**
- Kiwix (8430), files nginx (8888), Valhalla (8002), Nominatim (8010)
- Lower priority: these services do NOT trust `X-Authentik-Username` header
- Separate audit scheduled; network segmentation may be applied for consistency
3. **Postgres/Samba/NFS/CUPS**
- Separate infrastructure concerns, separate audit
- Not part of the Caddy/RECON auth migration scope
### 10.3 — RECON route inventory
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.
**Rollback:** git revert backend changes, restart `recon.service`.
### Phase 3 — Caddy selective public exposure
- 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.
**Rollback:** remove rate_limit matcher, reload Caddy.
### Phase 5 — Frontend 401 handling
- Implement chosen UX pattern from §8 (deferred decision).
- Visual gating for unauth-incompatible panels (contacts, etc.).
- Redirect-with-return-URL flow for one-shot authed actions.
- Test from incognito browser: every authed feature has a sensible empty/login-prompt state.
**Rollback:** revert frontend changes, rebuild, redeploy.
### Phase 6 — Observability
- 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. |
| Rate limiting model | Tiered: unauthenticated limited, authenticated unlimited |
| 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)
- Per-authed-user rate limits (single auth tier today; revisit if multi-user)
- "Aurora-lite" public read-only endpoint with token budget (interesting future option, explicitly deferred)
- GPX upload, image upload, or other upload features (not currently exposed)
- Tile-server CDN edge caching (Phase 3 hardening, not migration)
- Authentik group-based admin/user role separation (not needed for single-user)
---
## Appendix: CT 101 Caddy Audit Findings (2026-04-25)
### Audit Metadata
- **Caddy version:** v2.10.2
- **Config location:** `/etc/caddy/Caddyfile` (no imports, single file)
- **SSH access:** `ssh -J cortex root@192.168.1.101` or `ssh ct101` from cortex
- **Auditor:** Claude + Matt
- **Date:** 2026-04-25
### Executive Summary
**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.
**Verified exploitation:** With a spoofed `X-Authentik-Username: matt` header sent directly to 100.64.0.24:8420, we successfully:
- Read all contacts (name, phone, email, address, call sign)
- Added a test API key to the Gemini key pool
- Accessed the full API keys listing
**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**.
### Threat Model
| Attack Vector | Exploitable? | Notes |
|---------------|--------------|-------|
| External internet → Caddy → Backend | NO | forward_auth gates access, Authentik determines identity |
| LAN device → Backend (bypass Caddy) | **YES** | No firewall, backends listen on 0.0.0.0 |
| Tailscale device → Backend (bypass Caddy) | **YES** | Same as LAN, Tailscale routes directly to backend IPs |
| Compromised LAN device → Backend | **YES** | Lateral movement allows full impersonation |
### Per-Vhost Audit
| Site | Authentik Gated? | Backend | Backend Port | Backend Trusts Header? | Direct Access Exploitable? | Severity |
|------|------------------|---------|--------------|------------------------|---------------------------|----------|
| navi.echo6.co | Partial (/tiles/* public) | RECON | 8440 | Yes (contacts, place enrichment) | **YES** | HIGH |
| recon.echo6.co | Full | RECON | 8420 | Yes (all admin, contacts, keys) | **YES** | **CRITICAL** |
| files.echo6.co | Full | Static file server | 8888 | No (just nginx autoindex) | No | LOW |
| wiki.echo6.co | Full | Kiwix-serve | 8430 | No (kiwix has no user auth) | No | LOW |
| mesh.echo6.co | Full | MeshMonitor | 192.168.1.100:8080 | Unknown | TBD | MEDIUM |
| lidarr.echo6.co | Full | Lidarr | 100.64.0.18:8686 | No (uses own API key) | No | LOW |
| navidrome.echo6.co | Partial (/rest/* public) | Navidrome | 100.64.0.18:4533 | No (uses own auth) | No | LOW |
| ai.echo6.co | None | Open WebUI | 100.64.0.14:8080 | No (uses own auth) | No | LOW |
| stream.echo6.co | None | PeerTube | 192.168.1.170:80 | No (uses own auth) | No | LOW |
| immich.echo6.co | None | Immich | 192.168.1.182:2283 | No (uses own auth) | No | LOW |
| nextcloud.echo6.co | None | Nextcloud | 192.168.1.183:11000 | No (uses own auth) | No | LOW |
| jellyfin.echo6.co | None | Jellyfin | 100.64.0.18:8096 | No (uses own auth) | No | LOW |
| requests.echo6.co | None | Overseerr | 100.64.0.18:5055 | No (uses own auth) | No | LOW |
| nas.echo6.co | None | Pi-NAS | 100.64.0.21:80 | Unknown | TBD | LOW |
| echo6.co | None | SearXNG | 100.64.0.15:8080 | No | No | LOW |
| vpn.idahomesh.com | None | WireGuard UI | 192.168.1.106:8080 | Unknown | TBD | MEDIUM |
| ots.k7zvx.com | None | OpenTAK Server | 192.168.1.109:443 | No (uses own auth) | No | LOW |
### RECON Backend Binding Analysis
```
LISTEN 0.0.0.0:8420 # RECON dashboard/API - CRITICAL
LISTEN 0.0.0.0:8440 # Navi frontend - HIGH
LISTEN 0.0.0.0:8430 # Kiwix-serve - LOW (no user auth)
LISTEN 0.0.0.0:8888 # Static files - LOW (no user auth)
```
All services bind to `0.0.0.0` (all interfaces). No iptables rules restrict access. Any host on:
- LAN (192.168.1.0/24)
- Tailscale (100.64.0.0/10)
...can connect directly and bypass Caddy/Authentik entirely.
### Exploitation Proof
**Test 1: Read contacts with spoofed header**
```bash
$ curl -H 'X-Authentik-Username: matt' 'http://100.64.0.24:8420/api/contacts'
[{"name":"Matt Johnson","phone":"2083080811","email":"matt@echo6.co",...}]
```
**Test 2: Add API key with spoofed header**
```bash
$ curl -X POST -H 'X-Authentik-Username: matt' \
-H 'Content-Type: application/json' \
-d '{"key":"test-key-123"}' \
'http://100.64.0.24:8420/api/keys'
{"count":5,"index":4} # Key was added
```
**Test 3: Without header (baseline)**
```bash
$ curl 'http://100.64.0.24:8420/api/contacts'
{"error":"Authentication required"} # Correctly rejected
```
### Header Stripping Analysis
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 `header_up -X-Authentik-Username` directive would be defense-in-depth but is not the primary fix.
### Root Cause
The RECON backend trusts `X-Authentik-Username` header unconditionally, assuming all requests come through Caddy's forward_auth. This assumption fails when:
1. Backends are reachable directly (no firewall)
2. Attackers on LAN/Tailscale can bypass Caddy
### Severity Classification
| Classification | Criteria | Vhosts |
|----------------|----------|--------|
| **CRITICAL** | Backend reachable directly + trusts header + admin functions | recon.echo6.co (8420) |
| **HIGH** | Backend reachable directly + trusts header + user data | navi.echo6.co (8440) |
| **MEDIUM** | Backend reachable directly + unknown header trust | mesh.echo6.co, vpn.idahomesh.com |
| **LOW** | Backend has own auth OR no sensitive data | All others |
### Remediation Options
**Option A: Network Segmentation (Recommended)**
Firewall RECON VM to only accept connections from CT 101 (Caddy):
```bash
# On RECON VM (192.168.1.130 / 100.64.0.24)
iptables -A INPUT -p tcp --dport 8420 -s 192.168.1.101 -j ACCEPT
iptables -A INPUT -p tcp --dport 8420 -s 100.64.0.8 -j ACCEPT # Caddy Tailscale IP
iptables -A INPUT -p tcp --dport 8420 -j DROP
# Repeat for 8440, 8430, 8888
```
**Pros:** Blocks direct access entirely. Simple. No code changes.
**Cons:** Breaks recon-watchdog if it runs from a different host. Breaks any legitimate direct access patterns.
**Option B: Backend Auth Validation**
Modify RECON to validate the Authentik session, not just trust the header:
```python
def is_valid_authentik_user(username):
# Option B1: Check header came from trusted proxy IP
if request.remote_addr not in TRUSTED_PROXY_IPS:
return False
return True
# Option B2: Validate session with Authentik API (expensive)
# ...
```
**Pros:** Defense in depth. Works regardless of network topology.
**Cons:** Requires code changes. IP checking can be spoofed if attacker is on same subnet.
**Option C: Bind to Localhost Only**
Configure RECON services to bind to 127.0.0.1 or a Unix socket, then proxy from Caddy via localhost or socket.
**Pros:** Completely eliminates direct access.
**Cons:** Requires Caddy to run on same host as RECON, or use a local proxy. Architecture change.
### Recommended Priority
| Priority | Action | Rationale |
|----------|--------|-----------|
| **P0 (Today)** | Firewall RECON VM port 8420 to CT 101 only | Blocks critical admin access exploitation |
| **P1 (This week)** | Firewall ports 8440, 8430, 8888 | Blocks remaining RECON exploitation |
| **P2 (Before migration)** | Implement backend auth validation (Option B) | Defense in depth for public exposure |
| **P3 (Optional)** | Add `header_up -X-Authentik-*` to Caddy | Defense in depth, low priority |
### Verification Commands
After remediation, verify with:
```bash
# From cortex (should fail after firewall)
curl -H 'X-Authentik-Username: matt' 'http://100.64.0.24:8420/api/contacts'
# Expected: connection refused or timeout
# From CT 101 (should still work)
curl -H 'X-Authentik-Username: matt' 'http://100.64.0.24:8420/api/contacts'
# Expected: returns data (Caddy will set this header after auth)
```
### Appendix: Full Caddyfile Reference
The complete Caddyfile is available via:
```bash
ssh -J cortex root@192.168.1.101 'cat /etc/caddy/Caddyfile'
```
Key patterns observed:
- All Authentik-gated sites use identical `forward_auth` block
- `copy_headers` includes: X-Authentik-Username, X-Authentik-Groups, X-Authentik-Email, X-Authentik-Name, X-Authentik-Uid
- No sites use `header_up` to strip incoming headers (not needed for Caddy path, but noted for completeness)
- wiki.echo6.co uses `header_down -Content-Security-Policy` to strip CSP from Kiwix responses