From a4d9be19035f216ed0aa5514c1d87efa2f9e5eab Mon Sep 17 00:00:00 2001 From: acidburns Date: Mon, 2 Feb 2026 21:19:44 +0100 Subject: [PATCH] Harden history device ID validation and SD download filename --- include/html_util.h | 1 + src/html_util.cpp | 49 +++++++++++++++++++ src/web_server.cpp | 56 ++++++++++++++++++++-- test/test_html_escape/test_html_escape.cpp | 16 +++++++ 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/include/html_util.h b/include/html_util.h index 5ed1db4..02c96b4 100644 --- a/include/html_util.h +++ b/include/html_util.h @@ -4,3 +4,4 @@ String html_escape(const String &input); String url_encode_component(const String &input); +bool sanitize_device_id(const String &input, String &out_device_id); diff --git a/src/html_util.cpp b/src/html_util.cpp index dff470f..51b79d4 100644 --- a/src/html_util.cpp +++ b/src/html_util.cpp @@ -47,3 +47,52 @@ String url_encode_component(const String &input) { } return out; } + +static bool is_hex_char(char c) { + return (c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); +} + +static String to_upper_hex4(const String &input) { + String out = input; + out.toUpperCase(); + return out; +} + +bool sanitize_device_id(const String &input, String &out_device_id) { + String trimmed = input; + trimmed.trim(); + if (trimmed.length() == 0) { + return false; + } + if (trimmed.indexOf('/') >= 0 || trimmed.indexOf('\\') >= 0 || trimmed.indexOf("..") >= 0) { + return false; + } + if (trimmed.indexOf('%') >= 0) { + return false; + } + + if (trimmed.length() == 4) { + for (size_t i = 0; i < 4; ++i) { + if (!is_hex_char(trimmed[i])) { + return false; + } + } + out_device_id = String("dd3-") + to_upper_hex4(trimmed); + return true; + } + + if (trimmed.length() == 8 && trimmed.startsWith("dd3-")) { + String hex = trimmed.substring(4); + for (size_t i = 0; i < 4; ++i) { + if (!is_hex_char(hex[i])) { + return false; + } + } + out_device_id = String("dd3-") + to_upper_hex4(hex); + return true; + } + + return false; +} diff --git a/src/web_server.cpp b/src/web_server.cpp index c5d2bc7..e457a4e 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -140,6 +140,42 @@ static bool checkbox_checked(const char *name) { return val == "on" || val == "true" || val == "1"; } +static bool sanitize_history_device_id(const String &input, String &out_device_id) { + if (sanitize_device_id(input, out_device_id)) { + return true; + } + if (g_statuses) { + for (uint8_t i = 0; i < g_status_count; ++i) { + String known = g_statuses[i].last_data.device_id; + if (input.equalsIgnoreCase(known) && sanitize_device_id(known, out_device_id)) { + return true; + } + } + } + return false; +} + +static String sanitize_download_filename(const String &input, bool &clean) { + String out; + out.reserve(input.length()); + clean = true; + for (size_t i = 0; i < input.length(); ++i) { + unsigned char c = static_cast(input[i]); + if (c < 32 || c == 127 || c == '"' || c == '\\' || c == '/') { + out += '_'; + clean = false; + continue; + } + out += static_cast(c); + } + out.trim(); + if (out.length() == 0) { + out = "download.bin"; + clean = false; + } + return out; +} + static void history_reset() { if (g_history.file) { g_history.file.close(); @@ -597,7 +633,12 @@ static void handle_history_start() { server.send(200, "application/json", "{\"ok\":false,\"error\":\"time_not_synced\"}"); return; } - String device_id = server.arg("device_id"); + String device_id_arg = server.arg("device_id"); + String device_id; + if (!sanitize_history_device_id(device_id_arg, device_id)) { + server.send(200, "application/json", "{\"ok\":false,\"error\":\"bad_device_id\"}"); + return; + } uint16_t days = static_cast(server.arg("days").toInt()); uint16_t res_min = static_cast(server.arg("res").toInt()); String mode_str = server.arg("mode"); @@ -652,7 +693,12 @@ static void handle_history_data() { if (!ensure_auth()) { return; } - String device_id = server.arg("device_id"); + String device_id_arg = server.arg("device_id"); + String device_id; + if (!sanitize_history_device_id(device_id_arg, device_id)) { + server.send(200, "application/json", "{\"ready\":false,\"error\":\"bad_device_id\"}"); + return; + } if (!g_history.bins || device_id.length() == 0 || device_id != g_history.device_id) { server.send(200, "application/json", "{\"ready\":false,\"error\":\"no_job\"}"); return; @@ -716,7 +762,11 @@ static void handle_sd_download() { } size_t size = f.size(); String filename = path.substring(path.lastIndexOf('/') + 1); - server.sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + bool name_clean = true; + (void)name_clean; + String safe_name = sanitize_download_filename(filename, name_clean); + String cd = "attachment; filename=\"" + safe_name + "\"; filename*=UTF-8''" + url_encode_component(safe_name); + server.sendHeader("Content-Disposition", cd); server.setContentLength(size); const char *content_type = "application/octet-stream"; if (filename.endsWith(".csv")) { diff --git a/test/test_html_escape/test_html_escape.cpp b/test/test_html_escape/test_html_escape.cpp index 6ffbf8f..2f782cc 100644 --- a/test/test_html_escape/test_html_escape.cpp +++ b/test/test_html_escape/test_html_escape.cpp @@ -12,9 +12,25 @@ static void test_html_escape_basic() { TEST_ASSERT_EQUAL_STRING("&<>"'", html_escape("&<>\"'").c_str()); } +static void test_sanitize_device_id() { + String out; + TEST_ASSERT_TRUE(sanitize_device_id("F19C", out)); + TEST_ASSERT_EQUAL_STRING("dd3-F19C", out.c_str()); + TEST_ASSERT_TRUE(sanitize_device_id("dd3-f19c", out)); + TEST_ASSERT_EQUAL_STRING("dd3-F19C", out.c_str()); + TEST_ASSERT_FALSE(sanitize_device_id("F19G", out)); + TEST_ASSERT_FALSE(sanitize_device_id("dd3-12", out)); + TEST_ASSERT_FALSE(sanitize_device_id("dd3-12345", out)); + TEST_ASSERT_FALSE(sanitize_device_id("../F19C", out)); + TEST_ASSERT_FALSE(sanitize_device_id("dd3-%2f", out)); + TEST_ASSERT_FALSE(sanitize_device_id("dd3-12/3", out)); + TEST_ASSERT_FALSE(sanitize_device_id("dd3-12\\3", out)); +} + void setup() { UNITY_BEGIN(); RUN_TEST(test_html_escape_basic); + RUN_TEST(test_sanitize_device_id); UNITY_END(); }