Harden web UI auth, input handling, and SD path validation

- Add optional Basic Auth with NVS-backed credentials and STA/AP flags; protect status, wifi, history, and download routes

- Stop pre-filling WiFi/MQTT/Web UI password fields; keep stored secrets on blank and add clear-password checkboxes

- Add HTML escaping + URL encoding helpers and apply to user-controlled strings; add unit test

- Harden /sd/download path validation (prefix, length, dotdot, slashes) and log rejections

- Enforce protocol version in LoRa receive and release GPIO14 before SD init

- Update README security, SD, and GPIO sharing notes
This commit is contained in:
2026-02-02 21:07:37 +01:00
parent b5477262ea
commit 0e12b406de
10 changed files with 260 additions and 30 deletions

View File

@@ -4,6 +4,7 @@
#include "config.h"
#include "sd_logger.h"
#include "time_manager.h"
#include "html_util.h"
#include <SD.h>
#include <WiFi.h>
#include <time.h>
@@ -15,6 +16,8 @@ static const SenderStatus *g_statuses = nullptr;
static uint8_t g_status_count = 0;
static WifiMqttConfig g_config;
static bool g_is_ap = false;
static String g_web_user;
static String g_web_pass;
static const FaultCounters *g_sender_faults = nullptr;
static const FaultType *g_sender_last_errors = nullptr;
static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES];
@@ -50,11 +53,30 @@ struct HistoryJob {
static HistoryJob g_history = {};
static constexpr size_t SD_LIST_MAX_FILES = 200;
static constexpr size_t SD_DOWNLOAD_MAX_PATH = 160;
static bool auth_required() {
return g_is_ap ? WEB_AUTH_REQUIRE_AP : WEB_AUTH_REQUIRE_STA;
}
static bool ensure_auth() {
if (!auth_required()) {
return true;
}
const char *user = g_web_user.c_str();
const char *pass = g_web_pass.c_str();
if (server.authenticate(user, pass)) {
return true;
}
server.requestAuthentication(BASIC_AUTH, "DD3", "Authentication required");
return false;
}
static String html_header(const String &title) {
String safe_title = html_escape(title);
String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
h += "<title>" + title + "</title></head><body>";
h += "<h2>" + title + "</h2>";
h += "<title>" + safe_title + "</title></head><body>";
h += "<h2>" + safe_title + "</h2>";
return h;
}
@@ -78,6 +100,46 @@ static String format_faults(uint8_t idx) {
return s;
}
static bool sanitize_sd_download_path(String &path, String &error) {
path.trim();
if (path.length() == 0) {
error = "empty";
return false;
}
if (path.startsWith("dd3/")) {
path = "/" + path;
}
if (path.length() > SD_DOWNLOAD_MAX_PATH) {
error = "too_long";
return false;
}
if (!path.startsWith("/dd3/")) {
error = "prefix";
return false;
}
if (path.indexOf("..") >= 0) {
error = "dotdot";
return false;
}
if (path.indexOf('\\') >= 0) {
error = "backslash";
return false;
}
if (path.indexOf("//") >= 0) {
error = "repeated_slash";
return false;
}
return true;
}
static bool checkbox_checked(const char *name) {
if (!server.hasArg(name)) {
return false;
}
String val = server.arg(name);
return val == "on" || val == "true" || val == "1";
}
static void history_reset() {
if (g_history.file) {
g_history.file.close();
@@ -228,7 +290,9 @@ static String render_sender_block(const SenderStatus &status) {
}
}
String device_id = status.last_data.device_id;
s += "<strong><a href='/sender/" + device_id + "' target='_blank' rel='noopener noreferrer'>" + device_id + "</a></strong>";
String device_id_safe = html_escape(device_id);
String device_id_url = url_encode_component(device_id);
s += "<strong><a href='/sender/" + device_id_url + "' target='_blank' rel='noopener noreferrer'>" + device_id_safe + "</a></strong>";
if (status.has_data && status.last_data.link_valid) {
s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1);
}
@@ -271,14 +335,15 @@ static void append_sd_listing(String &html, const String &dir_path, uint8_t dept
}
}
if (entry.isDirectory()) {
html += "<li><strong>" + full_path + "/</strong></li>";
html += "<li><strong>" + html_escape(full_path) + "/</strong></li>";
append_sd_listing(html, full_path, depth + 1, count);
} else {
String href = full_path;
if (!href.startsWith("/")) {
href = "/" + href;
}
html += "<li><a href='/sd/download?path=" + href + "' target='_blank' rel='noopener noreferrer'>" + full_path + "</a>";
String href_enc = url_encode_component(href);
html += "<li><a href='/sd/download?path=" + href_enc + "' target='_blank' rel='noopener noreferrer'>" + html_escape(full_path) + "</a>";
html += " (" + String(entry.size()) + " bytes)</li>";
count++;
}
@@ -288,6 +353,9 @@ static void append_sd_listing(String &html, const String &dir_path, uint8_t dept
}
static void handle_root() {
if (!ensure_auth()) {
return;
}
String html = html_header("DD3 Bridge Status");
html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>";
@@ -316,16 +384,26 @@ static void handle_root() {
}
static void handle_wifi_get() {
if (!ensure_auth()) {
return;
}
String html = html_header("WiFi/MQTT Config");
html += "<form method='POST' action='/wifi'>";
html += "SSID: <input name='ssid' value='" + g_config.ssid + "'><br>";
html += "Password: <input name='pass' type='password' value='" + g_config.password + "'><br>";
html += "MQTT Host: <input name='mqhost' value='" + g_config.mqtt_host + "'><br>";
html += "SSID: <input name='ssid' value='" + html_escape(g_config.ssid) + "'><br>";
html += "Password: <input name='pass' type='password'> ";
html += "<label><input type='checkbox' name='clear_wifi_pass'> Clear password</label><br>";
html += "MQTT Host: <input name='mqhost' value='" + html_escape(g_config.mqtt_host) + "'><br>";
html += "MQTT Port: <input name='mqport' value='" + String(g_config.mqtt_port) + "'><br>";
html += "MQTT User: <input name='mquser' value='" + g_config.mqtt_user + "'><br>";
html += "MQTT Pass: <input name='mqpass' type='password' value='" + g_config.mqtt_pass + "'><br>";
html += "NTP Server 1: <input name='ntp1' value='" + g_config.ntp_server_1 + "'><br>";
html += "NTP Server 2: <input name='ntp2' value='" + g_config.ntp_server_2 + "'><br>";
html += "MQTT User: <input name='mquser' value='" + html_escape(g_config.mqtt_user) + "'><br>";
html += "MQTT Pass: <input name='mqpass' type='password'> ";
html += "<label><input type='checkbox' name='clear_mqtt_pass'> Clear password</label><br>";
html += "NTP Server 1: <input name='ntp1' value='" + html_escape(g_config.ntp_server_1) + "'><br>";
html += "NTP Server 2: <input name='ntp2' value='" + html_escape(g_config.ntp_server_2) + "'><br>";
html += "<hr>";
html += "Web UI User: <input name='webuser' value='" + html_escape(g_config.web_user) + "'><br>";
html += "Web UI Pass: <input name='webpass' type='password'> ";
html += "<label><input type='checkbox' name='clear_web_pass'> Clear password</label><br>";
html += "<div style='font-size:12px;color:#666;'>Leaving password blank keeps the existing one.</div>";
html += "<button type='submit'>Save</button>";
html += "</form>";
html += html_footer();
@@ -333,15 +411,38 @@ static void handle_wifi_get() {
}
static void handle_wifi_post() {
WifiMqttConfig cfg;
cfg.ntp_server_1 = "pool.ntp.org";
cfg.ntp_server_2 = "time.nist.gov";
if (!ensure_auth()) {
return;
}
WifiMqttConfig cfg = g_config;
cfg.ntp_server_1 = g_config.ntp_server_1.length() > 0 ? g_config.ntp_server_1 : "pool.ntp.org";
cfg.ntp_server_2 = g_config.ntp_server_2.length() > 0 ? g_config.ntp_server_2 : "time.nist.gov";
cfg.ssid = server.arg("ssid");
cfg.password = server.arg("pass");
String wifi_pass = server.arg("pass");
if (checkbox_checked("clear_wifi_pass")) {
cfg.password = "";
} else if (wifi_pass.length() > 0) {
cfg.password = wifi_pass;
}
cfg.mqtt_host = server.arg("mqhost");
cfg.mqtt_port = static_cast<uint16_t>(server.arg("mqport").toInt());
cfg.mqtt_user = server.arg("mquser");
cfg.mqtt_pass = server.arg("mqpass");
String mqtt_pass = server.arg("mqpass");
if (checkbox_checked("clear_mqtt_pass")) {
cfg.mqtt_pass = "";
} else if (mqtt_pass.length() > 0) {
cfg.mqtt_pass = mqtt_pass;
}
String web_user = server.arg("webuser");
if (web_user.length() > 0) {
cfg.web_user = web_user;
}
String web_pass = server.arg("webpass");
if (checkbox_checked("clear_web_pass")) {
cfg.web_pass = "";
} else if (web_pass.length() > 0) {
cfg.web_pass = web_pass;
}
if (server.arg("ntp1").length() > 0) {
cfg.ntp_server_1 = server.arg("ntp1");
}
@@ -349,6 +450,9 @@ static void handle_wifi_post() {
cfg.ntp_server_2 = server.arg("ntp2");
}
cfg.valid = true;
g_config = cfg;
g_web_user = cfg.web_user;
g_web_pass = cfg.web_pass;
wifi_save_config(cfg);
server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
delay(1000);
@@ -356,12 +460,16 @@ static void handle_wifi_post() {
}
static void handle_sender() {
if (!ensure_auth()) {
return;
}
if (!g_statuses) {
server.send(404, "text/plain", "No senders");
return;
}
String uri = server.uri();
String device_id = uri.substring(String("/sender/").length());
String device_id_url = url_encode_component(device_id);
for (uint8_t i = 0; i < g_status_count; ++i) {
if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
String html = html_header("Sender " + device_id);
@@ -376,6 +484,7 @@ static void handle_sender() {
html += "<canvas id='hist_canvas' width='320' height='140' style='width:100%;max-width:520px;border:1px solid #ccc;margin-top:6px;'></canvas>";
html += "</div>";
html += "<script>";
html += "const deviceId='" + device_id_url + "';";
html += "let histTimer=null;";
html += "function histStatus(msg){document.getElementById('hist_status').textContent=msg;}";
html += "function drawHistory(){";
@@ -383,7 +492,7 @@ static void handle_sender() {
html += "const res=document.getElementById('hist_res').value;";
html += "const mode=document.getElementById('hist_mode').value;";
html += "histStatus('Starting...');";
html += "fetch(`/history/start?device_id=" + device_id + "&days=${days}&res=${res}&mode=${mode}`)";
html += "fetch(`/history/start?device_id=${deviceId}&days=${days}&res=${res}&mode=${mode}`)";
html += ".then(r=>r.json()).then(j=>{";
html += "if(!j.ok){histStatus('Error: '+(j.error||'failed'));return;}";
html += "if(histTimer){clearInterval(histTimer);}";
@@ -392,7 +501,7 @@ static void handle_sender() {
html += "});";
html += "}";
html += "function fetchHistory(){";
html += "fetch(`/history/data?device_id=" + device_id + "`).then(r=>r.json()).then(j=>{";
html += "fetch(`/history/data?device_id=${deviceId}`).then(r=>r.json()).then(j=>{";
html += "if(!j.ready){histStatus(j.error?('Error: '+j.error):('Processing... '+(j.progress||0)+'%'));return;}";
html += "if(histTimer){clearInterval(histTimer);histTimer=null;}";
html += "renderChart(j.series);";
@@ -457,6 +566,9 @@ static void handle_sender() {
}
static void handle_manual() {
if (!ensure_auth()) {
return;
}
String html = html_header("DD3 Manual");
html += "<ul>";
html += "<li>Energy: total kWh since meter start.</li>";
@@ -474,6 +586,9 @@ static void handle_manual() {
}
static void handle_history_start() {
if (!ensure_auth()) {
return;
}
if (!sd_logger_is_ready()) {
server.send(200, "application/json", "{\"ok\":false,\"error\":\"sd_not_ready\"}");
return;
@@ -534,6 +649,9 @@ static void handle_history_start() {
}
static void handle_history_data() {
if (!ensure_auth()) {
return;
}
String device_id = server.arg("device_id");
if (!g_history.bins || device_id.length() == 0 || device_id != g_history.device_id) {
server.send(200, "application/json", "{\"ready\":false,\"error\":\"no_job\"}");
@@ -575,15 +693,19 @@ static void handle_history_data() {
}
static void handle_sd_download() {
if (!ensure_auth()) {
return;
}
if (!sd_logger_is_ready()) {
server.send(404, "text/plain", "SD not ready");
return;
}
String path = server.arg("path");
if (path.startsWith("dd3/")) {
path = "/" + path;
}
if (!path.startsWith("/dd3/")) {
String error;
if (!sanitize_sd_download_path(path, error)) {
if (SERIAL_DEBUG_MODE) {
Serial.printf("sd: reject path '%s' reason=%s\n", path.c_str(), error.c_str());
}
server.send(400, "text/plain", "Invalid path");
return;
}
@@ -618,6 +740,8 @@ static void handle_sd_download() {
void web_server_set_config(const WifiMqttConfig &config) {
g_config = config;
g_web_user = config.web_user;
g_web_pass = config.web_pass;
}
void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) {