137 lines
5.3 KiB
Python
137 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import gzip
|
|
from pathlib import Path
|
|
import re
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SERVER = ROOT / "src" / "helpers" / "esp32" / "WebConfigServer.cpp"
|
|
KEYS = ROOT / "src" / "helpers" / "WebConfigKeys.h"
|
|
UI = ROOT / "webui" / "index.html"
|
|
EMBEDDED_UI = ROOT / "src" / "helpers" / "esp32" / "WebConfigHtml.h"
|
|
|
|
|
|
def function_body(source: str, start: str, end: str) -> str:
|
|
return source[source.index(start) : source.index(end, source.index(start))]
|
|
|
|
|
|
def embedded_ui() -> str:
|
|
header = EMBEDDED_UI.read_text(encoding="utf-8")
|
|
length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1))
|
|
array = header.split(
|
|
"const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {", 1
|
|
)[1]
|
|
blob = bytes(
|
|
int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array)
|
|
)[:length]
|
|
return gzip.decompress(blob).decode("utf-8")
|
|
|
|
|
|
class WebConfigSecretMaskingTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.server = SERVER.read_text(encoding="utf-8")
|
|
cls.keys = KEYS.read_text(encoding="utf-8")
|
|
cls.ui = UI.read_text(encoding="utf-8")
|
|
match = re.search(
|
|
r'static const char SECRET_SENTINEL\[\]\s*=\s*"([^"]*)"\s*;',
|
|
cls.server,
|
|
)
|
|
if match is None:
|
|
raise AssertionError("WebConfigServer has no secret sentinel")
|
|
cls.sentinel = match.group(1)
|
|
|
|
def test_server_and_captive_portal_use_exactly_eight_stars(self):
|
|
self.assertEqual(self.sentinel, "********")
|
|
self.assertEqual(len(self.sentinel), 8)
|
|
|
|
browser = re.search(r'var SENTINEL\s*=\s*"([^"]*)"\s*;', self.ui)
|
|
self.assertIsNotNone(browser)
|
|
self.assertEqual(browser.group(1), self.sentinel)
|
|
|
|
captive_portal = re.search(
|
|
r'var SENTINEL\s*=\s*"([^"]*)"\s*;', embedded_ui()
|
|
)
|
|
self.assertIsNotNone(captive_portal)
|
|
self.assertEqual(captive_portal.group(1), self.sentinel)
|
|
|
|
def test_config_get_masks_only_nonempty_wifi_password(self):
|
|
get_handler = function_body(
|
|
self.server,
|
|
"void WebConfigServer::handleConfigGet(",
|
|
"void WebConfigServer::handleConfigPost(",
|
|
)
|
|
self.assertRegex(
|
|
get_handler,
|
|
r'wifi\["pwd"\]\s*=\s*_wifi_password\[0\]\s*'
|
|
r'\?\s*SECRET_SENTINEL\s*:\s*""\s*;',
|
|
)
|
|
self.assertEqual(get_handler.count('wifi["pwd"]'), 1)
|
|
self.assertNotRegex(
|
|
get_handler,
|
|
r'wifi\["pwd"\]\s*=\s*(?:\(const char\s*\*\)\s*)?'
|
|
r'_wifi_password\s*;',
|
|
)
|
|
|
|
def test_config_post_skips_only_exact_sentinel_for_secret_keys(self):
|
|
post_handler = function_body(
|
|
self.server,
|
|
"void WebConfigServer::handleConfigPost(",
|
|
"void WebConfigServer::handleConfigResult(",
|
|
)
|
|
skip = re.search(
|
|
r"if\s*\(\s*isSecretKey\(key\)\s*&&\s*"
|
|
r"strcmp\(val,\s*SECRET_SENTINEL\)\s*==\s*0\s*\)\s*continue\s*;",
|
|
post_handler,
|
|
)
|
|
self.assertIsNotNone(skip)
|
|
|
|
# The equality guard must run before a BatchEntry/CLI command is built;
|
|
# otherwise an unchanged masked field could persist the placeholder.
|
|
batch_entry = post_handler.index("BatchEntry& e")
|
|
pre_persistence = post_handler[
|
|
post_handler.index("int count = 0;") : batch_entry
|
|
]
|
|
self.assertEqual(pre_persistence.count("continue;"), 1)
|
|
self.assertLess(skip.start(), batch_entry)
|
|
self.assertLess(skip.start(), post_handler.index("count++;"))
|
|
|
|
# strcmp equality means close values remain real password updates, not
|
|
# wildcard matches. Keep representative boundary cases explicit.
|
|
def skipped(key: str, value: str) -> bool:
|
|
return key == "wifi.pwd" and value == self.sentinel
|
|
|
|
self.assertTrue(skipped("wifi.pwd", "********"))
|
|
for value in ("", "*******", "*********", "********x", "x********"):
|
|
self.assertFalse(skipped("wifi.pwd", value), value)
|
|
self.assertFalse(skipped("wifi.ssid", "********"))
|
|
|
|
def test_wifi_password_is_classified_as_a_secret_key(self):
|
|
secret_classifier = function_body(
|
|
self.keys,
|
|
"static inline bool wcIsSecretKey(",
|
|
"static inline bool wcIsSecretReadCommand(",
|
|
)
|
|
self.assertIn('strcmp(key, "wifi.pwd") == 0', secret_classifier)
|
|
|
|
def test_explicit_local_cli_reads_are_not_config_snapshots(self):
|
|
# Both terminal APIs require the authenticated LAN, and neither applies
|
|
# the config form's secret masking to an explicit command reply.
|
|
batch = function_body(self.server, "void WebConfigServer::drainBatch(",
|
|
"void WebConfigServer::attachRoutes(")
|
|
self.assertIn("execAdminCommand(e.cmd, e.reply)", batch)
|
|
self.assertNotIn("wcMaskSecretReply", batch)
|
|
for start, end in [
|
|
("void WebConfigServer::handleCliPost(", "void WebConfigServer::handleCliResult("),
|
|
("bool WebConfigServer::checkTerminalAccess(", "void WebConfigServer::handleTerminalPost("),
|
|
]:
|
|
handler = function_body(self.server, start, end)
|
|
self.assertIn("_mode != MODE_LAN", handler)
|
|
self.assertIn("checkAuth(req)", handler)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|