From 19c3d4912a0f2e221dca7bfb03497ce47aa7edc9 Mon Sep 17 00:00:00 2001 From: Gerard Hickey Date: Sun, 13 Sep 2026 15:57:01 -0400 Subject: [PATCH] feat(web_viewer): mask plugin password fields in settings (#273) Add a password type to settings_schema so plugin secrets render as masked inputs and never reach the browser. Stored values stay in config.ini; an empty box on save leaves an existing secret unchanged. --- CHANGELOG.md | 12 +++++ modules/settings_schema.py | 13 ++++-- modules/web_viewer/templates/plugins.html | 35 ++++++++++++++- tests/test_settings_ui.py | 55 +++++++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6296e57..13425f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ semantic versioning. which shares the English catalog but not US units. Repeater-selection distances stay in kilometres; only the printed placeholders convert. +- `password` field type for a plugin's `settings_schema`, so a secret like + a password for a command renders masked in the web viewer's Plugins page + instead of as plain text (#273). It validates and serializes exactly like `str` + (the value is still stored in plaintext in `config.ini`); the masking is a + UI concern only. The plaintext secret never reaches the browser: the view + blanks the value and reports only `has_value`, matching the key-name + redaction already used elsewhere, and the field renders as a + `type="password"` input with a show/hide toggle. When a value is already + saved the field shows a `(saved — enter new value to change)` placeholder + and leaving it untouched keeps the stored secret, so an edit elsewhere on + the form does not blank it. + - Localized proactive weather messages (daily forecasts, rain nowcasts, weather alerts) via `services.weather_service.*` translation keys. `WeatherService` now uses the bot's `translator` instead of hardcoded English strings, so proactive diff --git a/modules/settings_schema.py b/modules/settings_schema.py index a4de13f..aac0a28 100644 --- a/modules/settings_schema.py +++ b/modules/settings_schema.py @@ -19,7 +19,7 @@ Schema field format (a list of these dicts on ``settings_schema``):: { "key": "poll_interval", # config key within the section "label": "Poll interval", # human label - "type": "int", # bool|int|float|str|enum|list + "type": "int", # bool|int|float|str|enum|list|password "options": [{"value": "...", "label": "..."}], # required for enum "min": 1000, "max": None, # numeric bounds (int/float) "default": 60000, @@ -43,7 +43,7 @@ from typing import Any, Optional # so a plugin's on/off state displays correctly before the first canonical save. from modules.config_schema import LEGACY_ENABLED_ALIASES as _ENABLED_LEGACY_ALIASES -VALID_TYPES = {"bool", "int", "float", "str", "enum", "list"} +VALID_TYPES = {"bool", "int", "float", "str", "enum", "list", "password"} # Truthy/falsey string forms accepted for bool fields (configparser-compatible). _TRUE = {"1", "true", "yes", "on"} @@ -122,7 +122,8 @@ def validate_field(field: dict, raw: Any) -> tuple[bool, Any, Optional[str]]: return False, None, f"{label} contains an invalid value: {item}" return True, items, None - # str (default) + # str / password (password is a str stored in plaintext in config.ini, + # masked only in the web UI — same validation as str) s = str(raw) pattern = field.get("pattern") if pattern and not re.fullmatch(pattern, s): @@ -330,6 +331,12 @@ def _assemble_entry( continue resolved = dict(field) resolved["value"] = _read_typed(config, section, field) + if field.get("type") == "password": + # Never send the plaintext secret to the browser; the UI shows a + # placeholder when has_value is true and only submits a new value + # if the user actually types one. + resolved["has_value"] = bool(resolved["value"]) + resolved["value"] = "" fields.append(resolved) # Every command can restrict which channels it responds in diff --git a/modules/web_viewer/templates/plugins.html b/modules/web_viewer/templates/plugins.html index 559d7de..f6c7f0c 100644 --- a/modules/web_viewer/templates/plugins.html +++ b/modules/web_viewer/templates/plugins.html @@ -600,6 +600,36 @@ function renderField(field, overrideValue) { wrapper.appendChild(group); if (field.help) wrapper.appendChild(helpText(field.help)); return { wrapper, input }; + } else if (field.type === 'password') { + const group = document.createElement('div'); + group.className = 'input-group input-group-sm has-validation'; + input = document.createElement('input'); + input.className = 'form-control'; + input.type = 'password'; + input.autocomplete = 'new-password'; + if (field.pattern) input.pattern = field.pattern; + input.value = (value !== undefined && value !== null) ? value : ''; + input.placeholder = field.has_value ? '(saved — enter new value to change)' : ''; + group.appendChild(input); + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'btn btn-outline-secondary'; + const toggleIcon = document.createElement('i'); + toggleIcon.className = 'fas fa-eye'; + toggleBtn.appendChild(toggleIcon); + toggleBtn.addEventListener('click', () => { + const showing = input.type === 'text'; + input.type = showing ? 'password' : 'text'; + toggleIcon.classList.toggle('fa-eye', showing); + toggleIcon.classList.toggle('fa-eye-slash', !showing); + }); + group.appendChild(toggleBtn); + const fb = document.createElement('div'); + fb.className = 'invalid-feedback'; + group.appendChild(fb); + wrapper.appendChild(group); + if (field.help) wrapper.appendChild(helpText(field.help)); + return { wrapper, input }; } else { input = document.createElement('input'); input.className = 'form-control form-control-sm'; @@ -638,7 +668,7 @@ function fieldColClass(field) { if (field.type === 'int' || field.type === 'float' || field.type === 'enum') { return 'col-6 col-md-4 col-xl-3'; } - return 'col-12 col-sm-6 col-xl-4'; // str / list — medium + return 'col-12 col-sm-6 col-xl-4'; // str / list / password — medium } // Badge marking a field that reads/writes a shared section (not the plugin's own). @@ -870,6 +900,7 @@ function validateClient(field, raw) { const empty = (raw === '' || raw === null || raw === undefined); if (field.type === 'bool') return null; if (empty) { + if (field.type === 'password' && field.has_value) return null; // leave unchanged return field.required ? `${field.label} is required` : null; } if (field.type === 'int' || field.type === 'float') { @@ -883,7 +914,7 @@ function validateClient(field, raw) { const allowed = (field.options || []).map(o => String(o.value)); if (!allowed.includes(String(raw))) return `${field.label} is invalid`; } - if (field.type === 'str' && field.pattern) { + if ((field.type === 'str' || field.type === 'password') && field.pattern) { if (!new RegExp('^(?:' + field.pattern + ')$').test(raw)) return `${field.label} has an invalid format`; } return null; diff --git a/tests/test_settings_ui.py b/tests/test_settings_ui.py index 1e0dbd7..f75d6ab 100644 --- a/tests/test_settings_ui.py +++ b/tests/test_settings_ui.py @@ -15,6 +15,7 @@ import pytest from modules.ini_writer import backup_config, update_ini_values from modules.settings_schema import ( + _assemble_entry, build_plugin_settings_view, read_enabled, to_config_string, @@ -175,6 +176,20 @@ class TestValidateField: assert to_config_string({"type": "bool"}, True) == "true" assert to_config_string({"type": "list"}, ["a", "b"]) == "a, b" + def test_password_validates_like_str(self): + f = {"key": "x", "type": "password", "pattern": r"[0-9a-f]{4}"} + assert validate_field(f, "beef")[0] + assert not validate_field(f, "zzzz")[0] + + def test_password_required_empty_fails(self): + ok, _, err = validate_field( + {"key": "x", "type": "password", "required": True, "label": "API Password"}, "" + ) + assert not ok and "required" in err + + def test_password_to_config_string(self): + assert to_config_string({"type": "password"}, "s3cret") == "s3cret" + # --------------------------------------------------------------------------- # settings_schema.read_enabled — legacy aliases @@ -240,6 +255,46 @@ class TestBuildView: ) +class TestBuildViewPasswordRedaction: + """Password-typed fields must never leak their stored value to the UI; + the view should only report whether one is currently set.""" + + class _Dummy: + settings_schema = [ + {"key": "api_password", "type": "password", "label": "API Password"}, + ] + + def _field(self, cfg, section="Dummy_Command"): + entry = _assemble_entry( + cfg, + self._Dummy, + kind="command", + name="dummy", + section=section, + label="Dummy", + description="", + category="general", + enabled_default=True, + ) + return next(f for f in entry["fields"] if f["key"] == "api_password") + + def test_password_value_is_blanked_and_has_value_true(self): + """A password field with a stored value returns empty string + has_value=True.""" + cfg = configparser.ConfigParser() + cfg.add_section("Dummy_Command") + cfg.set("Dummy_Command", "api_password", "s3cret-value") + field = self._field(cfg) + assert field["value"] == "" + assert field["has_value"] is True + + def test_password_has_value_false_when_unset(self): + """A password field with no stored value returns empty string + has_value=False.""" + cfg = configparser.ConfigParser() + field = self._field(cfg) + assert field["value"] == "" + assert field["has_value"] is False + + # --------------------------------------------------------------------------- # settings_store # ---------------------------------------------------------------------------