|
| 1 | +# Security |
| 2 | + |
| 3 | +halo is a self-hosted, single-household wall dashboard. It runs on a home Pi and |
| 4 | +is reachable only from the LAN — it is **not** a public or multi-tenant service. |
| 5 | +The threat model is: everything on the LAN is trusted, so the controls that |
| 6 | +matter are keeping the upstream credentials (Hue bridge user, SolisCloud API |
| 7 | +key/secret, weather API key) out of the image and out of the browser, and never |
| 8 | +exposing the app beyond the LAN. |
| 9 | + |
| 10 | +## Trust boundaries |
| 11 | + |
| 12 | +- **Network is the boundary — there is no app auth.** Every route is public by |
| 13 | + design: no login, no session, no forward-auth, no `DEV_AUTH`. Anyone who can |
| 14 | + reach the port can read every reading and toggle every light. This is |
| 15 | + deliberate for a wall panel, and the only thing keeping it safe is reachability: |
| 16 | + halo sits behind Traefik on a subdomain whose DNS record — public though it is |
| 17 | + (`public_dns: True` in `../raspi`) — resolves to the **LAN IP**, with no port |
| 18 | + forward. Its route is deliberately **not** in Traefik's `_gated_hosts`, so |
| 19 | + there is no oauth2-proxy in front of it either. Making halo reachable from the |
| 20 | + internet therefore requires adding auth first; nothing in the app would stop |
| 21 | + it otherwise. |
| 22 | + |
| 23 | +- **No per-user isolation, by design.** There is no user model. `user_settings` |
| 24 | + is a single global row (`id=1`) shared by every viewer; anything written there |
| 25 | + is visible to everyone on the LAN. |
| 26 | + |
| 27 | +- **CORS is `Cors::permissive()`.** Acceptable only because the app is LAN-only |
| 28 | + and holds nothing a cross-origin read could escalate — there are no cookies, |
| 29 | + no session, and no auth header to steal, so a hostile page can at most do what |
| 30 | + any LAN client can already do directly. Tighten this to an origin allowlist if |
| 31 | + halo ever gains auth or leaves the LAN. |
| 32 | + |
| 33 | +- **No CSP / security response headers are set.** Same rationale; the SPA is |
| 34 | + served from `STATIC_DIR` with no user-supplied HTML anywhere in the response |
| 35 | + path. Worth adding if the trust model changes. |
| 36 | + |
| 37 | +- **Outbound fetches.** The backend calls fixed, config-pinned upstreams over |
| 38 | + verified TLS: the Hue bridge (LAN), FMI (WFS/WMS/download), tomorrow.io, |
| 39 | + SolisCloud, and the spot-price API. Base URLs come from env, not from request |
| 40 | + input, so no request can steer an outbound fetch at an arbitrary host. halo is |
| 41 | + deliberately **not** in `../raspi`'s `network_restrict.RESTRICTED` — unlike the |
| 42 | + LAN-only siblings it needs egress for those upstreams. |
| 43 | + |
| 44 | +- **`POST /api/pv/forecast` is an unauthenticated write.** The cron-driven |
| 45 | + `scripts/refresh-pv-forecast.sh` upserts ~66 rows through it. Any LAN client |
| 46 | + can also write those rows; the blast radius is a wrong PV forecast until the |
| 47 | + next 3-hourly refresh overwrites it. |
| 48 | + |
| 49 | +## Secrets |
| 50 | + |
| 51 | +All secrets are injected at runtime via env, never baked into the image or |
| 52 | +committed: `HUE_BRIDGE_USER`, `SOLIS_KEY_ID`, `SOLIS_KEY_SECRET`, |
| 53 | +`SOLIS_STATION_ID`, `TOMORROW_IO_API_KEY`. On the Pi they come from |
| 54 | +`/etc/secrets/halo.env` (written by `../raspi`'s `tasks/secrets.py`). `.env`, |
| 55 | +`.env.pv`, and `*.db*` are gitignored. The container runs as UID 1000 on |
| 56 | +`scratch`. |
| 57 | + |
| 58 | +## Accepted risks |
| 59 | + |
| 60 | +- **Unauthenticated light control and history writes from the LAN.** Accepted: |
| 61 | + the panel exists to be tapped without a login, and the LAN is the trust |
| 62 | + boundary. Revisit if halo is ever exposed beyond the LAN, or if guest/IoT |
| 63 | + devices share the same segment. |
| 64 | +- **Permissive CORS + no CSP.** Accepted for the same reason (see above). |
| 65 | + Revisit alongside any auth work. |
| 66 | + |
| 67 | +## Out of scope |
| 68 | + |
| 69 | +Authentication, per-user data, shared/team dashboards, and rate-limiting against |
| 70 | +a hostile LAN client are deliberately not built — see the root `CLAUDE.md` |
| 71 | +"Out of scope". Don't add them piecemeal; they change the trust model above. |
| 72 | + |
| 73 | +## Reporting |
| 74 | + |
| 75 | +This is a personal project. Flag an issue privately to the maintainer rather |
| 76 | +than opening a public issue with exploit detail. |
0 commit comments