diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c122af97a..25ea692d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Security +- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk ` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible. - **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at: - `POST /api/v1/recording/start` (`recording.rs` — `session_name`) - `GET /api/v1/recording/download/:id` (`recording.rs` — `id`) diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index 5b920154b4..f95ba1e698 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -38,14 +38,24 @@ static char s_ota_psk[OTA_PSK_MAX_LEN] = {0}; /** * ADR-050: Verify the Authorization header contains the correct PSK. - * Returns true if auth is disabled (no PSK provisioned) or if the - * Bearer token matches the stored PSK. + * Returns true only when a PSK is provisioned AND the Bearer token + * matches it. An unprovisioned node refuses all OTA requests + * (fail-closed, see RuView#596 audit). The OTA server still starts so + * the operator can `provision.py --ota-psk ` over USB-CDC without + * a reflash, but the upload endpoint will reject every request until + * the PSK is set. */ static bool ota_check_auth(httpd_req_t *req) { if (s_ota_psk[0] == '\0') { - /* No PSK provisioned — auth disabled (permissive for dev). */ - return true; + /* No PSK provisioned — fail closed. Previously this returned + * true ("permissive for dev"), which let any host on the WiFi + * push attacker-controlled firmware to a freshly-flashed node. + * Plain HTTP transport + no Secure Boot V2 + no signed-image + * verification meant a single LAN call could brick or back- + * door a node. Reject until provisioned. */ + ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk )"); + return false; } char auth_header[128] = {0}; @@ -250,11 +260,13 @@ esp_err_t ota_update_init(void) if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) { ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1); } else { - ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)"); + ESP_LOGW(TAG, "No OTA PSK in NVS — OTA upload endpoint will REJECT all requests until " + "provisioned (provision.py --ota-psk ). Fail-closed per RuView#596."); } nvs_close(nvs); } else { - ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", OTA_NVS_NAMESPACE); + ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA upload endpoint will REJECT all " + "requests until provisioned. Fail-closed per RuView#596.", OTA_NVS_NAMESPACE); } return ota_start_server(NULL); diff --git a/scripts/fix-markers.json b/scripts/fix-markers.json index abd53810d8..b93541bd39 100644 --- a/scripts/fix-markers.json +++ b/scripts/fix-markers.json @@ -188,6 +188,21 @@ "rationale": "Five endpoints used to embed user-controlled identifiers (session_name, model_id, dataset_id, recording id) into format!() paths with no sanitization, allowing classic '../../etc/passwd' reads, writes, and deletes on the server filesystem. The safe_id helper enforces [A-Za-z0-9._-] only (no leading '.', max 64 chars) and must run before any user input reaches a format!() that builds a path. Removing the helper or skipping it at any of these call sites reintroduces the #615 attack surface.", "ref": "https://github.com/ruvnet/RuView/issues/615" }, + { + "id": "RuView#596-ota-fail-closed", + "title": "ESP32 OTA upload fails closed when no PSK is provisioned", + "files": ["firmware/esp32-csi-node/main/ota_update.c"], + "require": [ + "fail-closed, see RuView#596 audit", + "OTA rejected: no PSK in NVS" + ], + "forbid": [ + "/auth disabled \\(permissive for dev\\)/", + "/No PSK provisioned \\u2014 auth disabled/" + ], + "rationale": "ota_check_auth previously returned true when s_ota_psk[0] == '\\0', so any host on the WiFi could push attacker-controlled firmware to a freshly-flashed node over plain HTTP on port 8032 — no Secure Boot V2, no signed-image verification, single LAN call could brick or backdoor a node. Flagged in the deep-review of PR #596. Fail-closed means the OTA server still starts (so operators can provision a PSK via USB-CDC without reflashing) but the upload endpoint refuses every request until provision.py --ota-psk writes the NVS key. Reverting this lets the rogue-LAN attack reopen.", + "ref": "https://github.com/ruvnet/RuView/pull/596#pullrequestreview" + }, { "id": "RuView#560", "title": "verify.py quantizes features before SHA-256 for cross-platform hash stability",