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.
This commit is contained in:
Gerard Hickey
2026-09-13 12:57:01 -07:00
committed by GitHub
parent f4903b11f6
commit 19c3d4912a
4 changed files with 110 additions and 5 deletions
+12
View File
@@ -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
+10 -3
View File
@@ -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
+33 -2
View File
@@ -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;
+55
View File
@@ -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
# ---------------------------------------------------------------------------