mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
Merge pull request #67 from cjkas/docs/security-plan
docs: plan auth on every entry point and one HTTP server
This commit is contained in:
commit
ea06f1bf53
2 changed files with 161 additions and 0 deletions
|
|
@ -141,3 +141,53 @@ The login password and PIN aren't affected. Neither can be empty, and security i
|
||||||
### Fix options
|
### Fix options
|
||||||
|
|
||||||
- **(a) "Clear saved password" checkbox** next to the WiFi passphrase and MQTT password fields, shown only when a value is stored. Since `""` means keep, ticking it sends an explicit flag (`clearPassphrase` / `clearPassword`), which `fromJSON` turns into `applySecret(..., identityChanged = true)`.
|
- **(a) "Clear saved password" checkbox** next to the WiFi passphrase and MQTT password fields, shown only when a value is stored. Since `""` means keep, ticking it sends an explicit flag (`clearPassphrase` / `clearPassword`), which `fromJSON` turns into `applySecret(..., identityChanged = true)`.
|
||||||
|
|
||||||
|
## Port 80 routes don't check login
|
||||||
|
|
||||||
|
**Status:** open. Fix planned as PR A of [SECURITY_PLAN.md](SECURITY_PLAN.md).
|
||||||
|
**Location:** `registerWebUiRoutes` in [src/entryPoints/WebUiRoutes.cpp](../src/entryPoints/WebUiRoutes.cpp).
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Only the ten port-80 routes that share a handler with port 8081 call `isAuthenticated`. The other ~60 check nothing, including every settings route, `/restore`, firmware updates, pairing and rolling codes. Logging in only hides parts of the UI. `serveStatic("/")` also serves `shades.cfg` and `shades.tmp`, which hold the rolling codes, to anyone.
|
||||||
|
|
||||||
|
### Concrete failure modes
|
||||||
|
|
||||||
|
1. With a PIN or password set, any LAN client can `PUT /saveSecurity` and replace them, or change the network, MQTT or radio settings.
|
||||||
|
2. Any LAN client can download `/shades.cfg` and read every shade's remote address and rolling code.
|
||||||
|
|
||||||
|
## API token key is public
|
||||||
|
|
||||||
|
**Status:** open. Fix planned as PR B of [SECURITY_PLAN.md](SECURITY_PLAN.md).
|
||||||
|
**Location:** `Web::createAPIToken` in [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp).
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Tokens are `HMAC(serverId, PIN-or-password + client IP)`. `serverId` is the last 3 bytes of the MAC address, and it is published in mDNS, in the retained MQTT `serverId` topic, in `/discovery` and in `/loginContext`. So the HMAC key gives no protection: whoever sees a token can try every PIN offline. Tokens never expire, and they are compared with a non-constant-time `String` comparison. `serverId` itself is an identity (HA's unique id) and stays public. Only the key is replaced.
|
||||||
|
|
||||||
|
## CORS allows any origin
|
||||||
|
|
||||||
|
**Status:** open. Fix planned as PR A of [SECURITY_PLAN.md](SECURITY_PLAN.md).
|
||||||
|
**Location:** `DefaultHeaders` in `Web::begin`, [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp).
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Every response carries `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: PUT,POST,GET,OPTIONS` and `Access-Control-Allow-Headers: *`. Any website open in a browser on the LAN can call the device and read the replies. With security None it can change every setting, without the user noticing.
|
||||||
|
|
||||||
|
## Websocket has no auth
|
||||||
|
|
||||||
|
**Status:** open. Fix planned as PR A of [SECURITY_PLAN.md](SECURITY_PLAN.md).
|
||||||
|
**Location:** `SocketEmitter::wsEvent` in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.cpp).
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Anyone can connect to `ws://host:8080/` and join any room. Clients can't change anything over it, but they receive all shade and group state, and room 0 (the frame log) carries received remote addresses and rolling codes. Browsers don't apply CORS to websockets, so a page from any origin can connect too.
|
||||||
|
|
||||||
|
## `/login` has no rate limit
|
||||||
|
|
||||||
|
**Status:** open. Fix planned as PR C of [SECURITY_PLAN.md](SECURITY_PLAN.md).
|
||||||
|
**Location:** `Web::handleLogin` in [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp).
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Failed logins aren't throttled. A digits-only 4-character PIN has 10,000 options and falls to an online attack within minutes. An alphanumeric one has about 1.7 million and lasts a day or two at about 10 attempts per second.
|
||||||
|
|
|
||||||
111
docs/SECURITY_PLAN.md
Normal file
111
docs/SECURITY_PLAN.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Plan: authentication on every entry point, one HTTP server
|
||||||
|
|
||||||
|
**Status:** agreed, nothing built yet.
|
||||||
|
**Written:** 2026-10-18, on top of PR #64 (stored secrets are write-only). Terms are defined in [GLOSSARY.md](../GLOSSARY.md).
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
- **Port 80 doesn't check login.** About 60 of its 70 routes call no auth check at all: settings, security, network, MQTT, radio, firmware, `/restore`, pairing, rolling codes. Only the ten that share a handler with port 8081 check it (`/controller`, `/rooms`, `/shades`, `/groups`, `/room`, `/shade`, `/group`, `/backup`, `/reboot`, `/downloadFirmware`). Logging in only hides parts of the UI.
|
||||||
|
- **The token key is public.** API tokens are `HMAC(serverId, PIN-or-password + client IP)`. `serverId` is the last 3 bytes of the MAC address, which every device on the LAN can see, and it is also published in mDNS, in the retained MQTT `serverId` topic and in `/discovery`. Anyone who sees a token can recover a 4-character PIN offline. Tokens never expire.
|
||||||
|
- **`/login` has no rate limit.** A digits-only PIN (10,000 options) falls to an online attack within minutes.
|
||||||
|
- **CORS is wide open.** Every response carries `Access-Control-Allow-Origin: *` and `Allow-Headers: *`. Any website open in a browser on the LAN can call the device and read the replies.
|
||||||
|
- **The websocket has no auth.** It is read-only, but room 0 (the frame log) carries received remote addresses and rolling codes.
|
||||||
|
- **`serveStatic("/")` serves all of LittleFS**, including `shades.cfg` and `shades.tmp`.
|
||||||
|
- **Three servers for one device.** Port 80 (UI), 8081 (API) and 8080 (websocket). 8081 dates from upstream's blocking `WebServer`, so that Home Assistant calls didn't wait behind UI file downloads. Every server is now an `AsyncWebServer` on the same AsyncTCP task, and every API route except `/discovery` is already registered on port 80 too.
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
- **In scope:** another client on the same LAN that can send HTTP and websocket requests. That includes a browser on the LAN running a malicious page.
|
||||||
|
- **Out of scope:** someone sniffing traffic (everything is plain HTTP, and TLS on the ESP32 is out of reach), and a device exposed to the internet.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Auth rules
|
||||||
|
- Every entry point applies the rules `Web::isAuthenticated(request, cfg)` already applies on 8081. No new rules are invented.
|
||||||
|
- **Security None:** open.
|
||||||
|
- **PIN or password:** a valid `apikey` is required.
|
||||||
|
- **Secure Config Only:** control and read routes are open; config routes need `apikey`.
|
||||||
|
- `serverId` stays public everywhere, including `/loginContext`. It is an identity, and HA uses it as the device's unique id. Only the token key changes (PR B).
|
||||||
|
|
||||||
|
### Route access levels (port 80 and the 8081 alias)
|
||||||
|
- **Public:** static web assets, `/loginContext`, `/login`, `/upnp.xml` (SSDP), OPTIONS preflight requests.
|
||||||
|
- **Control** (`cfg=false`):
|
||||||
|
- reads: `/controller`, `/rooms`, `/shades`, `/groups`, `/room`, `/shade`, `/group`, `/discovery`
|
||||||
|
- commands: `/shadeCommand`, `/groupCommand`, `/tiltCommand`, `/repeatCommand`, `/setPositions`, `/setSensor`, `/setMyPosition`
|
||||||
|
- `/groupOptions`
|
||||||
|
- **Config** (`cfg=true`): everything else:
|
||||||
|
- settings get/set: security, network, MQTT, radio, general, IP, `/connectwifi`, `/scanaps`, `/modulesettings`
|
||||||
|
- pairing, rolling code, linking, deleting, `/getNext*`
|
||||||
|
- firmware and filesystem: `/getReleases`, `/downloadFirmware`, `/cancelFirmware`, `/update*`, `/recoverFilesystem`
|
||||||
|
- `/backup`, `/restore`, `/reboot`, `/shades.cfg`, `/shades.tmp`
|
||||||
|
- `/sendRemoteCommand`, `/beginFrequencyScan`, `/endFrequencyScan`
|
||||||
|
- The table lives in one pure function with native tests, not in ~70 hand-placed checks.
|
||||||
|
- `serveStatic` refuses `*.cfg` and `*.tmp`, so the rolling codes are only reachable through the authenticated routes.
|
||||||
|
|
||||||
|
### Websocket
|
||||||
|
- **Handshake:** the token goes in `?apikey=<token>`, because browsers can't set headers on a websocket. `handleHandshake` checks it with the same comparison the HTTP routes use and stores an `authenticated` flag on the client slot. `request->url()` is the path only, so the token doesn't reach the slow-request log.
|
||||||
|
- **Rules:**
|
||||||
|
- **Security None:** open.
|
||||||
|
- **PIN or password:** the handshake needs a valid token.
|
||||||
|
- **Secure Config Only:** anyone can connect; only an authenticated connection can join room 0.
|
||||||
|
- **Logging in with a socket already open:** the UI reconnects the socket with the token. There is no in-band upgrade message.
|
||||||
|
- **Origin:** a handshake whose `Origin` header doesn't match `Host` is refused.
|
||||||
|
|
||||||
|
### CORS
|
||||||
|
- The wildcard `Access-Control-Allow-*` default headers go. The UI is served from the same origin, and HA and scripts aren't browsers, so CORS doesn't affect them.
|
||||||
|
- A build flag turns the headers back on for UI development from `file://`, which `baseUrl` in `index.js` supports.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- On a 401, the UI opens the login panel (`security.authUser()`) and drops that request, with no automatic retry. The user repeats the action.
|
||||||
|
|
||||||
|
### Token key (PR B)
|
||||||
|
- The token keeps its shape, `HMAC(key, PIN-or-password + client IP)`: stateless, and it survives reboots. Only the key changes.
|
||||||
|
- **Key:** 32 random bytes from `esp_fill_random`, generated on first boot in their own NVS namespace. Never sent anywhere, never in a backup, replaced only when NVS is erased. There is no rotate button: changing the PIN or password already changes every token.
|
||||||
|
- **Comparison:** constant-time, used for HTTP and the websocket handshake alike.
|
||||||
|
- **Migration:** none needed. The browser keeps its token in memory only, and HA logs in again at runtime (HA PR).
|
||||||
|
|
||||||
|
### Login throttling (PR C)
|
||||||
|
- Per client IP: after 5 failed `/login` attempts, refuse that IP for 30 s, doubling each time up to 15 min. A success resets it.
|
||||||
|
- Only `/login` is throttled. After PR B a token is a 256-bit HMAC with a secret key, so guessing one through `apikey` is hopeless.
|
||||||
|
|
||||||
|
### One server on port 80
|
||||||
|
- Recorded as ADR-0003, "One HTTP server on port 80; 8081 and 8080 are deprecated aliases", plus an "API and ports" section in the README for script and Node-RED users.
|
||||||
|
- **Port 80 serves everything:** the UI, the API (with `/discovery` added) and the websocket at `ws://host/ws`. The mDNS service record moves to port 80, with a TXT entry for the websocket path.
|
||||||
|
- **Aliases:** 8081 and 8080 keep working for one release, with the same routes and the same auth.
|
||||||
|
- **Deprecation signal:** `ESP_LOGW` once per boot on the first use of 8081 or 8080, plus a release-notes entry.
|
||||||
|
- **Removal:** in the first firmware release after the HA fork's port-80 release is out.
|
||||||
|
|
||||||
|
### Home Assistant ([ESPSomfy-RTS-HA](https://github.com/cjkas/ESPSomfy-RTS-HA))
|
||||||
|
- Today HA logs in only during the config flow, on a temporary API object, and sends `apikey: ""` at runtime. It only works with security None or Secure Config Only.
|
||||||
|
- **Ports:** it probes `http://host/discovery` on port 80 and falls back to 8081/8080 on a 404, so it works with firmware from before PR 0.
|
||||||
|
- **Login:** at `async_setup_entry` it logs in with the PIN or password stored in `entry.data`. On a 401 it logs in again once and retries; if that fails, it raises HA's reauth flow.
|
||||||
|
- **Websocket:** it adds `?apikey=` to the websocket URL.
|
||||||
|
|
||||||
|
## PR sequence
|
||||||
|
|
||||||
|
| # | Repo | Scope | Breaks anything? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | firmware | `/discovery` and `/ws` on port 80, UI switches to `/ws`; deprecation log on 8081/8080; ADR-0003 and README "API and ports" | no |
|
||||||
|
| HA | HA fork | port-80 probe with fallback; runtime login, re-login on 401, reauth flow; `?apikey=` on the websocket | no |
|
||||||
|
| A | firmware | access table on port 80 and the 8081 alias; websocket handshake auth and room 0; `*.cfg`/`*.tmp` static filter; UI 401 handling; CORS headers removed; Origin check | old HA with full security (already broken today) |
|
||||||
|
| B | firmware | random HMAC key in NVS; constant-time token comparison | no (tokens are re-issued on login) |
|
||||||
|
| C | firmware | per-IP `/login` backoff | no |
|
||||||
|
| D | firmware | remove the 8081 and 8080 aliases, one release after HA's port-80 release | scripts still on 8081/8080 |
|
||||||
|
|
||||||
|
The HA PR must merge and ship before PR A.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
The rules go into small pure helpers with native tests, following [src/Secrets.h](../src/Secrets.h):
|
||||||
|
- the route-to-access-level table
|
||||||
|
- the token comparison
|
||||||
|
- the Origin/Host match
|
||||||
|
- the backoff schedule
|
||||||
|
|
||||||
|
Everything else is checked with the `esp32devdbg` build, plus curl and websocket checks against a device under each security type.
|
||||||
|
|
||||||
|
## Not in this plan
|
||||||
|
|
||||||
|
- TLS.
|
||||||
|
- Session tokens with expiry.
|
||||||
|
- Contents of the binary backup file (it holds no passwords, but it does hold rolling codes; `/backup` becomes a config route).
|
||||||
Loading…
Add table
Add a link
Reference in a new issue