Compare commits

..
Author SHA1 Message Date
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
7 changed files with 146 additions and 149 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B

+1 -63
View File
@@ -111,12 +111,6 @@ class RestoreOptions:
"""Which sections of a backup should be restored."""
restore_config: bool = True
#: Whether to take the backup's display.hardware block as well.
#: Off by default: that block describes the panel physically wired to
#: *this* device -- its size, chain length, mapping, multiplexing and
#: refresh cap. A backup carries the panel of the machine it was taken
#: on, and restoring one onto a different rig drives the wrong geometry.
restore_hardware: bool = False
restore_secrets: bool = True
restore_wifi: bool = True
restore_fonts: bool = True
@@ -555,60 +549,6 @@ def _copy_file(src: Path, dst: Path) -> None:
raise
_HARDWARE_PATH = ("display", "hardware")
def _restore_config_preserving_hardware(src: Path, dst: Path, keep_hardware: bool) -> None:
"""Copy a backed-up config.json, optionally keeping the local panel block.
display.hardware describes the panel physically attached to this device:
cols, rows, chain_length, hardware_mapping, panel_type, multiplexing and
the refresh-rate cap. None of that travels with a configuration -- it is a
property of the machine. Restoring a backup taken on a 512x64 rig onto a
128x32 one used to overwrite the smaller panel's geometry with the larger
one's, which is not a setting the user can see going wrong; the display
simply stops being right.
Falls back to a plain copy when either file cannot be parsed, so a restore
never fails because of this.
"""
if not keep_hardware:
_copy_file(src, dst)
return
try:
incoming = json.loads(src.read_text(encoding="utf-8"))
local = json.loads(dst.read_text(encoding="utf-8")) if dst.exists() else {}
except (OSError, ValueError) as exc:
logger.warning(
"[Backup] Could not merge local panel config (%s); restoring the "
"backup's config.json as-is", exc)
_copy_file(src, dst)
return
section, key = _HARDWARE_PATH
local_hw = (local.get(section) or {}).get(key)
if not isinstance(local_hw, dict) or not local_hw:
_copy_file(src, dst)
return
if not isinstance(incoming.get(section), dict):
incoming[section] = {}
incoming_hw = incoming[section].get(key)
incoming[section][key] = local_hw
if isinstance(incoming_hw, dict) and incoming_hw != local_hw:
logger.info(
"[Backup] Kept this device's display.hardware; the backup's panel "
"was %sx%s chain %s, this one is %sx%s chain %s",
incoming_hw.get("cols"), incoming_hw.get("rows"),
incoming_hw.get("chain_length"),
local_hw.get("cols"), local_hw.get("rows"),
local_hw.get("chain_length"))
tmp_path = dst.with_suffix(dst.suffix + ".restore-tmp")
tmp_path.write_text(json.dumps(incoming, indent=2) + "\n", encoding="utf-8")
os.replace(tmp_path, dst)
def restore_backup(
zip_path: Path,
project_root: Path,
@@ -644,9 +584,7 @@ def restore_backup(
# Main config.
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
try:
_restore_config_preserving_hardware(
tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL,
keep_hardware=not options.restore_hardware)
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
result.restored.append("config")
except OSError as e:
logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True)
+34 -1
View File
@@ -143,6 +143,12 @@ def mask_secret_fields(config: Dict[str, Any], schema_properties: Dict[str, Any]
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.
@@ -161,7 +167,7 @@ def mask_all_secret_values(config: Dict[str, Any]) -> Dict[str, Any]:
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] = '••••••••'
masked[k] = SECRET_MASK
else:
masked[k] = v
return masked
@@ -189,3 +195,30 @@ def remove_empty_secrets(secrets: Dict[str, Any]) -> Dict[str, Any]:
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
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""A restore must not repoint this device at another machine's panel.
display.hardware describes the panel physically wired to this device -- cols,
rows, chain_length, hardware_mapping, panel_type, multiplexing, the refresh
cap. A backup carries the panel of the machine it was taken on. Restoring a
512x64 rig's backup onto a 128x32 one used to overwrite the smaller panel's
geometry with the larger one's, and nothing on screen explains why: the
display just stops being right.
That is not hypothetical. It happened, and the rig it happened to had to be
reflashed.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.backup_manager import _restore_config_preserving_hardware # noqa: E402
BIG = {"display": {"hardware": {"cols": 128, "rows": 64, "chain_length": 4,
"hardware_mapping": "adafruit-hat-pwm"},
"runtime": {"gpio_slowdown": 4}},
"timezone": "America/New_York", "some-plugin": {"enabled": True}}
SMALL = {"display": {"hardware": {"cols": 64, "rows": 32, "chain_length": 2,
"hardware_mapping": "regular"},
"runtime": {"gpio_slowdown": 2}},
"timezone": "UTC"}
def _run(tmp, keep):
src = tmp / "backup_config.json"; src.write_text(json.dumps(BIG))
dst = tmp / "config.json"; dst.write_text(json.dumps(SMALL))
_restore_config_preserving_hardware(src, dst, keep_hardware=keep)
return json.loads(dst.read_text())
def test_local_panel_survives(tmp_path):
out = _run(tmp_path, keep=True)
hw = out["display"]["hardware"]
assert (hw["cols"], hw["rows"], hw["chain_length"]) == (64, 32, 2), (
"the restore repointed this device at the backup's panel")
assert hw["hardware_mapping"] == "regular", "panel wiring came from the backup"
def test_everything_else_is_restored(tmp_path):
out = _run(tmp_path, keep=True)
assert out["timezone"] == "America/New_York", "config was not restored"
assert out["some-plugin"] == {"enabled": True}, "plugin config was not restored"
assert out["display"]["runtime"] == {"gpio_slowdown": 4}, (
"only display.hardware should be held back")
def test_opting_in_takes_the_backups_panel(tmp_path):
out = _run(tmp_path, keep=False)
hw = out["display"]["hardware"]
assert (hw["cols"], hw["rows"], hw["chain_length"]) == (128, 64, 4)
def test_a_device_with_no_local_hardware_takes_the_backups(tmp_path):
src = tmp_path / "b.json"; src.write_text(json.dumps(BIG))
dst = tmp_path / "c.json"; dst.write_text(json.dumps({"timezone": "UTC"}))
_restore_config_preserving_hardware(src, dst, keep_hardware=True)
out = json.loads(dst.read_text())
assert out["display"]["hardware"]["cols"] == 128, (
"nothing local to preserve, so the backup's panel should be used")
def test_unparseable_local_config_still_restores(tmp_path):
src = tmp_path / "b.json"; src.write_text(json.dumps(BIG))
dst = tmp_path / "c.json"; dst.write_text("{ not json")
_restore_config_preserving_hardware(src, dst, keep_hardware=True)
assert json.loads(dst.read_text())["timezone"] == "America/New_York", (
"a restore must never fail because of this merge")
@@ -0,0 +1,82 @@
"""GET /config/secrets must not hand out credentials, and the client's
read-modify-write cycle must not destroy them.
This interface has no authentication. The endpoint returned the whole
config_secrets.json to anyone who could reach the port; on one rig that was a
40-character GitHub token, a 183-character Home Assistant token and three API
keys. Masking it alone is not enough: the only client fetches every secret,
edits one field and posts all of them back, so the write path has to treat an
echoed mask as "unchanged".
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from test_api_v3_secret_roundtrip import env, _on_disk # noqa: F401,E402
from src.web_interface.secret_helpers import SECRET_MASK # noqa: E402
STORED = {
"github": {"api_token": "ghp_" + "x" * 36},
"ledmatrix-weather": {"api_key": "w" * 32},
"incoming-packages": {"ha_token": "h" * 183},
"unset-plugin": {"api_key": ""},
"placeholder-plugin": {"api_key": "YOUR_API_KEY_HERE"},
}
def _seed(env):
env.secrets_file.write_text(json.dumps(STORED))
def _get(env):
r = env.client.get("/api/v3/config/secrets")
assert r.status_code == 200, r.get_data(as_text=True)[:200]
return r.get_json()["data"]
def test_no_credential_leaves_the_process(env):
_seed(env)
body = json.dumps(_get(env))
for secret in ("ghp_" + "x" * 36, "w" * 32, "h" * 183):
assert secret not in body, "endpoint returned a stored credential"
def test_set_and_unset_remain_distinguishable(env):
_seed(env)
data = _get(env)
assert data["github"]["api_token"] == SECRET_MASK
assert data["unset-plugin"]["api_key"] == ""
assert data["placeholder-plugin"]["api_key"] == "YOUR_API_KEY_HERE"
def test_the_clients_read_modify_write_preserves_every_other_secret(env):
"""What the GitHub-token save button actually does."""
_seed(env)
secrets = _get(env) # everything arrives masked
secrets["github"]["api_token"] = "ghp_" + "n" * 36 # user changes one
r = env.client.post("/api/v3/config/raw/secrets", json=secrets)
assert r.status_code == 200, r.get_data(as_text=True)[:200]
on_disk = _on_disk(env.secrets_file)
assert on_disk["github"]["api_token"] == "ghp_" + "n" * 36, "new token not saved"
assert on_disk["ledmatrix-weather"]["api_key"] == "w" * 32
assert on_disk["incoming-packages"]["ha_token"] == "h" * 183
def test_a_mask_echoed_back_is_never_stored(env):
_seed(env)
env.client.post("/api/v3/config/raw/secrets", json=_get(env))
on_disk = _on_disk(env.secrets_file)
assert SECRET_MASK not in json.dumps(on_disk), "the mask was stored as a secret"
assert on_disk["github"]["api_token"] == "ghp_" + "x" * 36
def test_a_brand_new_secret_can_still_be_added(env):
_seed(env)
env.client.post("/api/v3/config/raw/secrets",
json={"new-plugin": {"api_key": "brand-new"}})
on_disk = _on_disk(env.secrets_file)
assert on_disk["new-plugin"]["api_key"] == "brand-new"
assert on_disk["github"]["api_token"] == "ghp_" + "x" * 36
+21 -4
View File
@@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
# Import new infrastructure
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
from src.web_interface.errors import ErrorCode
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values,
separate_secrets, strip_masked_values)
from src.web_interface.error_handler import describe_exception, redact_text
from src.plugin_system.operation_types import OperationType
from src.web_interface.validators import (
@@ -1333,7 +1334,12 @@ def get_secrets_config():
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
config = api_v3.config_manager.get_raw_file_content('secrets')
return jsonify({'status': 'success', 'data': config})
# This interface has no authentication, and this file is nothing but
# credentials. It was handing all of them to anyone who could reach
# the port. Values are masked; empty and YOUR_* placeholders are left
# alone so a client can still tell "set" from "not set".
return jsonify({'status': 'success',
'data': mask_all_secret_values(config)})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@@ -1395,8 +1401,19 @@ def save_raw_secrets_config():
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Save the secrets config
api_v3.config_manager.save_raw_file_content('secrets', data)
# The GET above masks what it returns, and this endpoint's only client
# reads the whole file, edits one field and posts all of it back. So
# most of what arrives here is the mask, echoed rather than changed --
# storing it verbatim would replace every untouched credential with
# eight bullets. Strip those, then merge onto what is already stored,
# which makes "unchanged" mean unchanged.
#
# The cost is that a secret can no longer be cleared by blanking it.
# That needs its own affordance; a control that erases credentials as
# a side effect of saving an unrelated one is not it.
current = api_v3.config_manager.get_raw_file_content('secrets') or {}
merged = deep_merge(current, strip_masked_values(data))
api_v3.config_manager.save_raw_file_content('secrets', merged)
# Reload GitHub token in plugin store manager if it exists
if api_v3.plugin_store_manager:
+8 -6
View File
@@ -4622,15 +4622,17 @@ window.loadGithubToken = function() {
// Handle empty data (secrets file doesn't exist) - API returns {} in this case
const secrets = data.data || {};
const token = secrets.github?.api_token || '';
const configured = token && token !== 'YOUR_GITHUB_PERSONAL_ACCESS_TOKEN';
if (input) {
if (token && token !== 'YOUR_GITHUB_PERSONAL_ACCESS_TOKEN') {
// Token exists and is valid
input.value = token;
showNotification('GitHub token loaded successfully', 'success');
// The endpoint masks what it returns, so this never holds
// the real token -- and the field is deliberately left
// empty rather than filled with the mask, which would be
// saved verbatim the next time the user pressed Save.
input.value = '';
if (configured) {
showNotification('A GitHub token is saved. Enter a new one to replace it.', 'success');
} else {
// No token configured or placeholder value
input.value = '';
showNotification('No GitHub token configured. Enter a new token to save.', 'info');
}
}