fix(settings): a plugin's password is sealed at rest, and a blank box keeps it (#5671)
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user