mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-05 13:57:54 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user