fix(settings): a plugin's password is sealed at rest, and a blank box keeps it (#5671)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m13s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m2s

The UniFi password sat in plugin.unifi in plain text: SECRET_KEYS covers
whole settings, and a plugin's settings are one dict. Saving the plugin's
settings page also wiped it, since the password box is never filled back
in and its blank value was stored.

- A plugin field whose name holds password, secret or token is a
  credential (is_plugin_secret). set_setting seals it inside the dict,
  every read opens it, startup seals any plaintext left from before, and
  the undecryptable-secrets banner names it as plugin.<name>.<field>.
- The settings page uses the same test for its password boxes, and a blank
  box keeps the saved value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 07:06:31 -04:00
co-authored by Claude Opus 5.5
parent 002181253e
commit eea29123f9
6 changed files with 191 additions and 3 deletions
+3
View File
@@ -155,6 +155,9 @@ def create_app(
for _name in ("metric_label", "metric_about", "metric_unit", "metric_value", "source_label",
"cert_left", "field_label"):
app.jinja_env.globals[_name] = getattr(_fmt, _name)
# Which plugin settings fields are credentials (a password box, never echoed).
from .core.settings import is_plugin_secret as _is_plugin_secret
app.jinja_env.globals["is_plugin_secret"] = _is_plugin_secret
from .alerts.service import rule_condition as _rule_condition
app.jinja_env.globals["rule_condition"] = _rule_condition
from .models.monitors import MONITOR_TYPE_LABELS as _type_labels
+52
View File
@@ -204,6 +204,11 @@ def _decode(value: Any, key: str = "") -> Any:
exact setting that needs re-entering.
"""
from steward.core.crypto import decrypt_secret, is_encrypted
if key.startswith(PLUGIN_PREFIX) and isinstance(value, dict):
name = key[len(PLUGIN_PREFIX):]
return {f: decrypt_secret(v, context=plugin_secret_key(name, f))
if is_plugin_secret(f) and is_encrypted(v) else v
for f, v in value.items()}
return decrypt_secret(value, context=key) if is_encrypted(value) else value
@@ -260,6 +265,38 @@ def source_token_key(name: str) -> str:
return f"{SOURCES_KEY}[{name}].http_token"
# A plugin's settings are one dict under plugin.<name>; a field whose name
# says it holds a credential is sealed inside it, field by field. The same
# test decides that the settings page shows it as a password box.
PLUGIN_PREFIX = "plugin."
_PLUGIN_SECRET_WORDS = ("password", "secret", "token")
def is_plugin_secret(field: str) -> bool:
"""Whether a plugin config field holds a credential: unifi's password,
any *_secret or *_token."""
return any(w in field for w in _PLUGIN_SECRET_WORDS)
def seal_plugin_secrets(cfg: Any) -> tuple[Any, int]:
"""Encrypt each plaintext secret field of one plugin's settings dict;
returns (cfg, how many)."""
from steward.core.crypto import encrypt_secret, is_encrypted
if not isinstance(cfg, dict):
return cfg, 0
sealed, count = dict(cfg), 0
for field, value in cfg.items():
if is_plugin_secret(field) and isinstance(value, str) and value and not is_encrypted(value):
sealed[field] = encrypt_secret(value)
count += 1
return sealed, count
def plugin_secret_key(name: str, field: str) -> str:
"""How a plugin's secret is named in logs and the undecryptable-secrets banner."""
return f"{PLUGIN_PREFIX}{name}.{field}"
# ─────────────────────────────────────────────────────────────────────────────
# Async helpers (use inside request handlers / scheduled tasks)
# ─────────────────────────────────────────────────────────────────────────────
@@ -281,6 +318,8 @@ async def set_setting(session: AsyncSession, key: str, value: Any) -> None:
if key in SECRET_KEYS and isinstance(value, str) and value:
from steward.core.crypto import encrypt_secret
to_store = encrypt_secret(value)
elif key.startswith(PLUGIN_PREFIX):
to_store, _ = seal_plugin_secrets(value)
result = await session.execute(
select(AppSetting).where(AppSetting.key == key)
)
@@ -569,6 +608,12 @@ def migrate_plaintext_secrets(db_url: str) -> int:
if sealed:
row.value_json = json.dumps(sources)
converted += sealed
for row in (await session.execute(select(AppSetting).where(
AppSetting.key.startswith(PLUGIN_PREFIX)))).scalars():
cfg, sealed = seal_plugin_secrets(json.loads(row.value_json))
if sealed:
row.value_json = json.dumps(cfg)
converted += sealed
finally:
await engine.dispose()
return converted
@@ -605,6 +650,13 @@ def scan_undecryptable_secrets(db_url: str) -> list[str]:
key = source_token_key(src.get("name", "?"))
if _is_undecryptable(src.get("http_token"), key):
bad.append(key)
for row in (await session.execute(select(AppSetting).where(
AppSetting.key.startswith(PLUGIN_PREFIX)))).scalars():
cfg = json.loads(row.value_json)
for field, value in (cfg.items() if isinstance(cfg, dict) else ()):
key = plugin_secret_key(row.key[len(PLUGIN_PREFIX):], field)
if is_plugin_secret(field) and _is_undecryptable(value, key):
bad.append(key)
finally:
await engine.dispose()
return bad
+7
View File
@@ -15,6 +15,7 @@ from steward.core.settings import (
get_stored_plugin_names, find_orphaned_plugins,
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg, to_access_cfg, DEFAULTS, seal_source_tokens,
is_plugin_secret,
)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
@@ -830,6 +831,12 @@ async def save_plugin_detail(name: str):
old_enabled = to_plugins_cfg(old_settings).get(name, {}).get("enabled", False)
plugin_cfg = _build_plugin_cfg_from_form(plugin, form)
# A secret is never sent back to the browser, so its box arrives blank
# unless someone typed a new one: blank keeps the saved value.
old_cfg = to_plugins_cfg(old_settings).get(name, {})
for field, value in list(plugin_cfg.items()):
if is_plugin_secret(field) and value == "" and old_cfg.get(field):
plugin_cfg[field] = old_cfg[field]
async with current_app.db_sessionmaker() as db:
async with db.begin():
@@ -108,10 +108,11 @@
{% else %}
<div class="form-group" style="margin:0;">
<label style="font-size:0.82rem;">{{ field_label(cfg_key) }}</label>
<input type="{% if 'password' in cfg_key or 'secret' in cfg_key %}password{% else %}text{% endif %}"
{% set secret = is_plugin_secret(cfg_key) %}
<input type="{% if secret %}password{% else %}text{% endif %}"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
value="{% if 'password' in cfg_key or 'secret' in cfg_key %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}"
{% if 'password' in cfg_key or 'secret' in cfg_key %}placeholder="••••••••"{% endif %}>
value="{% if secret %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}"
{% if secret %}autocomplete="new-password" placeholder="{{ 'kept; type to change' if plugin._config.get(cfg_key) else 'none saved' }}"{% endif %}>
</div>
{% endif %}