Files
LEDMatrix/src/web_interface/secret_helpers.py
T
ChuckBuildsandClaude Opus 5 d2379a1eb6 fix(web): stop /config/secrets handing out every credential it holds
GET /api/v3/config/secrets returned config_secrets.json in full to anyone who
could reach the port, and this interface has no authentication. Probed against
a real rig it produced six populated credential fields: a 40-character GitHub
token, a 183-character Home Assistant token, and Jellyfin and weather API keys.
This is the second door onto the same credentials; #477 closes the first.

Masking the response alone would have been worse than the leak. The only
client fetches every secret, edits one field and posts all of them back, and
save_raw_file_content replaces the file wholesale -- so a masked GET followed
by the client's own save would write the mask over every credential the user
had not touched. That is why this was left open when the leak was found; it
needs both halves.

Read side: mask_all_secret_values(), which already existed for exactly this
endpoint -- its docstring names it -- and had never been wired to a call site.
It leaves empty values and YOUR_* placeholders alone, so a client can still
tell "set" from "not set" without being told the secret.

Write side: strip the echoed mask and blanks from the submission, then merge
onto what is stored, so "unchanged" means unchanged. The cost is that a secret
can no longer be cleared by blanking it; that wants its own affordance, since
a control that erases credentials as a side effect of saving an unrelated one
is not one.

Browser side: the token field is now left empty rather than filled from the
response. Filling it with the mask would have stored eight bullet characters
as the token the next time the user pressed Save, and filling it with the real
value is the thing being fixed. It reports whether a token is saved instead.

Verified end to end through the Flask endpoints, not the helpers. Reverting
the masking fails the leak tests; reverting the merge fails the preservation
tests; both halves are independently guarded. 278 web tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-20 12:13:12 -04:00

225 lines
9.2 KiB
Python

"""
Secret handling helpers for the web interface.
Provides functions for identifying, masking, separating, and filtering
secret fields in plugin configurations based on JSON Schema x-secret markers.
"""
from typing import Any, Dict, Set, Tuple
def find_secret_fields(properties: Dict[str, Any], prefix: str = '') -> Set[str]:
"""Find all fields marked with ``x-secret: true`` in a JSON Schema properties dict.
Recurses into nested objects and array items to discover secrets at any
depth (e.g. ``accounts[].token``).
Args:
properties: The ``properties`` dict from a JSON Schema.
prefix: Dot-separated prefix for nested field paths (used in recursion).
Returns:
A set of dot-separated field paths (e.g. ``{"api_key", "auth.token"}``).
"""
fields: Set[str] = set()
if not isinstance(properties, dict):
return fields
for field_name, field_props in properties.items():
if not isinstance(field_props, dict):
continue
full_path = f"{prefix}.{field_name}" if prefix else field_name
if field_props.get('x-secret', False):
fields.add(full_path)
if field_props.get('type') == 'object' and 'properties' in field_props:
fields.update(find_secret_fields(field_props['properties'], full_path))
# Recurse into array items (e.g. accounts[].token)
if field_props.get('type') == 'array' and isinstance(field_props.get('items'), dict):
items_schema = field_props['items']
if items_schema.get('x-secret', False):
fields.add(f"{full_path}[]")
if items_schema.get('type') == 'object' and 'properties' in items_schema:
fields.update(find_secret_fields(items_schema['properties'], f"{full_path}[]"))
return fields
def separate_secrets(
config: Dict[str, Any], secret_paths: Set[str], prefix: str = ''
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Split a config dict into regular and secret portions.
Uses the set of dot-separated secret paths (from :func:`find_secret_fields`)
to partition values. Empty nested dicts are dropped from the regular
portion to match the original inline behavior. Handles array-item secrets
using ``[]`` notation in paths (e.g. ``accounts[].token``).
Args:
config: The full plugin config dict.
secret_paths: Set of dot-separated paths identifying secret fields.
prefix: Dot-separated prefix for nested paths (used in recursion).
Returns:
A ``(regular, secrets)`` tuple of dicts.
"""
regular: Dict[str, Any] = {}
secrets: Dict[str, Any] = {}
for key, value in config.items():
full_path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
nested_regular, nested_secrets = separate_secrets(value, secret_paths, full_path)
if nested_regular:
regular[key] = nested_regular
if nested_secrets:
secrets[key] = nested_secrets
elif isinstance(value, list):
# Check if array elements themselves are secrets
array_path = f"{full_path}[]"
if array_path in secret_paths:
secrets[key] = value
else:
# Check if array items have nested secret fields
has_nested = any(p.startswith(f"{array_path}.") for p in secret_paths)
if has_nested:
reg_items = []
sec_items = []
for item in value:
if isinstance(item, dict):
r, s = separate_secrets(item, secret_paths, array_path)
reg_items.append(r)
sec_items.append(s)
else:
reg_items.append(item)
sec_items.append({})
regular[key] = reg_items
if any(sec_items):
secrets[key] = sec_items
else:
regular[key] = value
elif full_path in secret_paths:
secrets[key] = value
else:
regular[key] = value
return regular, secrets
def mask_secret_fields(config: Dict[str, Any], schema_properties: Dict[str, Any]) -> Dict[str, Any]:
"""Mask config values for fields marked ``x-secret: true`` in the schema.
Replaces each present secret value with an empty string so that API
responses never expose plain-text secrets. Non-secret values are
returned unchanged. Recurses into nested objects and array items.
Args:
config: The plugin config dict (may contain secret values).
schema_properties: The ``properties`` dict from the plugin's JSON Schema.
Returns:
A copy of *config* with secret values replaced by ``''``.
Nested dicts containing secrets are also copied (not mutated in place).
"""
result = dict(config)
for fname, fprops in schema_properties.items():
if not isinstance(fprops, dict):
continue
if fprops.get('x-secret', False):
# Mask any present value — including falsey ones like 0 or False
if fname in result and result[fname] is not None and result[fname] != '':
result[fname] = ''
elif fprops.get('type') == 'object' and 'properties' in fprops:
if fname in result and isinstance(result[fname], dict):
result[fname] = mask_secret_fields(result[fname], fprops['properties'])
elif fprops.get('type') == 'array' and isinstance(fprops.get('items'), dict):
items_schema = fprops['items']
if fname in result and isinstance(result[fname], list):
if items_schema.get('x-secret', False):
# Entire array elements are secrets — mask each
result[fname] = ['' for _ in result[fname]]
elif items_schema.get('type') == 'object' and 'properties' in items_schema:
# Recurse into each array element's properties
result[fname] = [
mask_secret_fields(item, items_schema['properties'])
if isinstance(item, dict) else item
for item in result[fname]
]
return result
#: What a masked secret looks like on the wire. Named because the write path
#: has to recognise it coming back: a client that renders the mask and posts
#: it unchanged must not store the mask as if it were the secret.
SECRET_MASK = '\u2022' * 8
def mask_all_secret_values(config: Dict[str, Any]) -> Dict[str, Any]:
"""Blanket-mask every non-empty value in a secrets config dict.
Used by the ``GET /config/secrets`` endpoint where all values are secret
by definition. Placeholder strings (``YOUR_*``) and empty/None values are
left as-is so the UI can distinguish "not set" from "set".
Args:
config: A raw secrets config dict (e.g. from ``config_secrets.json``).
Returns:
A copy with all real values replaced by ``'••••••••'``.
"""
masked: Dict[str, Any] = {}
for k, v in config.items():
if isinstance(v, dict):
masked[k] = mask_all_secret_values(v)
elif v not in (None, '') and not (isinstance(v, str) and v.startswith('YOUR_')):
masked[k] = SECRET_MASK
else:
masked[k] = v
return masked
def remove_empty_secrets(secrets: Dict[str, Any]) -> Dict[str, Any]:
"""Remove empty / whitespace-only / None values from a secrets dict.
When the GET endpoint masks secret values to ``''``, a subsequent POST
will send those empty strings back. This filter strips them so that
existing stored secrets are not overwritten with blanks.
Args:
secrets: A secrets dict that may contain masked empty values.
Returns:
A copy with empty entries removed. Empty nested dicts are pruned.
"""
result: Dict[str, Any] = {}
for k, v in secrets.items():
if isinstance(v, dict):
nested = remove_empty_secrets(v)
if nested:
result[k] = nested
elif v is not None and not (isinstance(v, str) and v.strip() == ''):
result[k] = v
return result
def strip_masked_values(secrets: Dict[str, Any]) -> Dict[str, Any]:
"""Remove values a client echoed back rather than changed.
The counterpart to :func:`mask_all_secret_values`. A client that GETs the
masked secrets, edits one field and POSTs the whole object back is sending
``SECRET_MASK`` for every field it did not touch. Storing those would
replace each untouched credential with eight bullet characters.
Drops the mask and, like :func:`remove_empty_secrets`, blank values -- so
the caller can merge the result onto what is already stored and have
"unchanged" mean unchanged. Empty nested dicts are pruned.
"""
result: Dict[str, Any] = {}
for k, v in secrets.items():
if isinstance(v, dict):
nested = strip_masked_values(v)
if nested:
result[k] = nested
elif v is None:
continue
elif isinstance(v, str) and (v.strip() == '' or v == SECRET_MASK):
continue
else:
result[k] = v
return result