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 notice is what shows next, and Vegas resumes after it; before, a rotation
screen showed instead and the notice expired behind it. An active screen showed instead and the notice expired behind it. An active
on-demand session still holds the panel until it ends. 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. - 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 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 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. calls.
""" """
import html
import json import json
import re
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -221,3 +223,66 @@ class TestRawEndpointsBypassSecretSeparation:
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}}) env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
# Nothing was moved aside into the secrets file. # Nothing was moved aside into the secrets file.
assert not env.secrets_file.exists() or "PLAINTEXT-KEY" not in env.secrets_file.read_text() 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_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_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$') _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.plugin_system.schema_manager import plugin_config_defaults, prepare_plugin_config
from src.common.path_safety import resolve_under, safe_path_component from src.common.path_safety import resolve_under, safe_path_component
from src.pi5_matrix_support import is_raspberry_pi_5 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') main_config_data = pages_v3.config_manager.get_raw_file_content('main')
# The web login section (password and token hashes) is managed in # The web login section (password and token hashes) is managed in
# General > Security, never in this editor; its save keeps it. # 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 from web_interface.auth import strip_auth_section
secrets_config_data = strip_auth_section( secrets_config_data = mask_all_secret_values(strip_auth_section(
pages_v3.config_manager.get_raw_file_content('secrets')) pages_v3.config_manager.get_raw_file_content('secrets')))
main_config_json = json.dumps(main_config_data, indent=4) main_config_json = json.dumps(main_config_data, indent=4)
secrets_config_json = json.dumps(secrets_config_data, indent=4) secrets_config_json = json.dumps(secrets_config_data, indent=4)