From 90fd5f22f7a511bb0791148e247b08d583c52cb8 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 18 May 2026 08:55:34 -0400 Subject: [PATCH] fix(firmware): OTA upload fails closed when no PSK in NVS (RuView#596 audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ota_check_auth() previously returned true when s_ota_psk[0] == '\0' ("permissive for dev"). A freshly-flashed node — or any node where nobody had provisioned an OTA PSK yet — accepted attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification, no transport encryption. Single LAN call could brick or backdoor a node. This was flagged in the deep security review of PR #596 but was a PRE-EXISTING bug in main, not new code from that PR — so it stood as a critical-severity production issue until this commit. Fix: - ota_check_auth() now returns false when no PSK is provisioned, with ESP_LOGW("OTA rejected: no PSK in NVS …") at the call site so the operator can diagnose the rejection from serial logs - ota_update_init() ESP_LOGW message updated to surface the new posture at boot ("upload endpoint will REJECT all requests until provisioned") - Doc comment on ota_check_auth() rewritten to make the contract explicit and reference the audit The OTA HTTP server itself still starts even when no PSK is set. That lets the operator run `provision.py --ota-psk ` over USB-CDC to write the NVS key without reflashing the firmware. The upload endpoint just refuses every request in the meantime. Breaking change for any deployment that depended on the unauthenticated OTA path working out of the box. Documented in CHANGELOG under [Unreleased] / Security so it's visible at the next release cut. Fix-marker RuView#596-ota-fail-closed (scripts/fix-markers.json) requires the new behaviour and forbids the old "permissive for dev" fallback strings, so a future revert fails CI. Co-Authored-By: claude-flow --- CHANGELOG.md | 1 + firmware/esp32-csi-node/main/ota_update.c | 24 +++++++++++++++++------ scripts/fix-markers.json | 15 ++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) 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",