fix(web): mask the Config Editor's secrets like GET /config/secrets

The Config Editor tab (/partials/raw-json) filled its config_secrets.json
editor with the file as it is on disk. GET /api/v3/config/secrets masks every
value because the interface is reachable without a login by default, but
this page handed the same credentials (GitHub token, Home Assistant token,
plugin API keys) to anyone who loaded it. The masked-save path in
save_raw_secrets_config was written for a masked editor and never got one.

_load_raw_json_partial now masks the section with mask_all_secret_values
after strip_auth_section, exactly as the GET does. Saving it back is safe:
save_raw_secrets_config drops the masks (strip_masked_values) and merges the
rest onto the stored file (deep_merge), so an untouched secret stays as it
is and a replaced mask is the only value that changes.

The config.json editor is left as it is. Its save (save_raw_main_config)
writes the posted object verbatim, with no mask stripping or merge, so a
masked main editor would write the bullets over any credential it holds.
Masking it needs a merge-on-save of its own first.

Tests: TestConfigEditorRoundTrip renders the partial over a real
ConfigManager, checks no real value is in the editor, and posts the editor
back unchanged (the file is identical) and with one mask replaced (only that
value changes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 20:49:54 -04:00
co-authored by Claude Opus 5.5
parent ef69201770
commit 4c5bba34ef
3 changed files with 83 additions and 3 deletions
+10
View File
@@ -540,6 +540,16 @@ policies are unchanged.
notice is what shows next, and Vegas resumes after it; before, a rotation
screen showed instead and the notice expired behind it. An active
on-demand session still holds the panel until it ends.
- The Config Editor tab no longer shows API keys and tokens in plain
text. Its `config_secrets.json` editor (`/partials/raw-json`) was filled
with the file as it is on disk, so while the web login is off (the
default) anyone who could reach the port could read every credential,
although `GET /api/v3/config/secrets` masks them. The editor now shows the
same masked values. Saving it unchanged changes nothing, because the save
drops the masks and merges onto the stored file; to change a secret,
replace its mask. A list of secrets still needs every entry's real value
to be changed. The `config.json` editor is unchanged: its save writes the
file as given, so a mask there would be stored.
- A game that goes live now takes over the panel within about a second.
Live priority was only checked between screens, so a game that went live
during a 30 s screen waited for that screen to end. The frame loops and the
@@ -13,7 +13,9 @@ tmp_path so the assertions are against files on disk rather than mock
calls.
"""
import html
import json
import re
import sys
from pathlib import Path
from unittest.mock import MagicMock
@@ -221,3 +223,66 @@ class TestRawEndpointsBypassSecretSeparation:
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
# Nothing was moved aside into the secrets file.
assert not env.secrets_file.exists() or "PLAINTEXT-KEY" not in env.secrets_file.read_text()
class TestConfigEditorRoundTrip:
"""The Config Editor tab (/partials/raw-json) and the save it posts to.
The secrets editor is shown masked, like GET /config/secrets: the page is
served to anyone who can reach the port while the optional web login is
off. Its save strips the masks and merges onto the stored file, so a
masked editor saved back as it is changes nothing.
"""
STORED = {
"github": {"api_token": "ghp_REAL_TOKEN_1234"},
"ledmatrix-weather": {"api_key": "WEATHER_KEY_abcdef", "units_id": 42},
"calendar": {"accounts": [{"name": "home", "token": "CAL_TOKEN_9"}]},
"youtube": {"api_key": "YOUR_YOUTUBE_API_KEY", "channel_secret": ""},
}
REAL_VALUES = ("ghp_REAL_TOKEN_1234", "WEATHER_KEY_abcdef", "CAL_TOKEN_9")
@pytest.fixture
def editor(self, env, monkeypatch):
from web_interface.blueprints import pages_v3 as pages_module
env.secrets_file.write_text(json.dumps(self.STORED))
monkeypatch.setattr(pages_module.pages_v3, "config_manager",
env.config_manager, raising=False)
app = Flask(__name__, template_folder=str(project_root / "web_interface" / "templates"))
app.config["TESTING"] = True
app.register_blueprint(pages_module.pages_v3)
app.register_blueprint(api_v3, url_prefix="/api/v3")
return app.test_client()
@staticmethod
def _secrets_textarea(client):
page = client.get("/partials/raw-json")
assert page.status_code == 200
match = re.search(r'<textarea id="secrets-config-editor"[^>]*>(.*?)</textarea>',
page.get_data(as_text=True), re.S)
assert match, "the secrets editor is missing from the partial"
return html.unescape(match.group(1))
def test_the_editor_shows_no_secret_value(self, editor):
text = self._secrets_textarea(editor)
for value in self.REAL_VALUES:
assert value not in text
shown = json.loads(text)
assert shown["github"]["api_token"] == "\u2022" * 8
# Same shape as the file, and "not set" still reads as not set.
assert shown["calendar"]["accounts"][0]["name"] == "\u2022" * 8
assert shown["youtube"] == {"api_key": "YOUR_YOUTUBE_API_KEY", "channel_secret": ""}
def test_saving_it_back_unchanged_keeps_every_secret(self, editor, env):
shown = json.loads(self._secrets_textarea(editor))
response = editor.post(SECRETS, json=shown)
assert response.status_code == 200
assert json.loads(env.secrets_file.read_text()) == self.STORED
def test_editing_one_secret_changes_only_that_one(self, editor, env):
shown = json.loads(self._secrets_textarea(editor))
shown["ledmatrix-weather"]["api_key"] = "NEW_WEATHER_KEY"
assert editor.post(SECRETS, json=shown).status_code == 200
expected = json.loads(json.dumps(self.STORED))
expected["ledmatrix-weather"]["api_key"] = "NEW_WEATHER_KEY"
assert json.loads(env.secrets_file.read_text()) == expected
+8 -3
View File
@@ -11,7 +11,7 @@ _SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
from src.web_interface.secret_helpers import mask_secret_fields
from src.web_interface.secret_helpers import mask_all_secret_values, mask_secret_fields
from src.plugin_system.schema_manager import plugin_config_defaults, prepare_plugin_config
from src.common.path_safety import resolve_under, safe_path_component
from src.pi5_matrix_support import is_raspberry_pi_5
@@ -623,9 +623,14 @@ def _load_raw_json_partial():
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
# The web login section (password and token hashes) is managed in
# General > Security, never in this editor; its save keeps it.
# The rest is masked, as GET /api/v3/config/secrets masks it: this
# page is served to anyone who can reach the port while the web
# login is off, and it was handing them every credential in the
# file. The save strips the masks and merges onto the stored file
# (save_raw_secrets_config), so a value left masked stays as it is.
from web_interface.auth import strip_auth_section
secrets_config_data = strip_auth_section(
pages_v3.config_manager.get_raw_file_content('secrets'))
secrets_config_data = mask_all_secret_values(strip_auth_section(
pages_v3.config_manager.get_raw_file_content('secrets')))
main_config_json = json.dumps(main_config_data, indent=4)
secrets_config_json = json.dumps(secrets_config_data, indent=4)