From fc6104f2296a8fb125b77793e19e1dd72f23a9de Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Thu, 20 Aug 2026 05:09:12 -0400 Subject: [PATCH] fix(web): stop /config/main handing out every credential it holds The endpoint returned the raw config to anyone who could reach the port, and this web interface has no authentication of any kind. An unauthenticated request against a live rig returned: github.api_token 40 chars incoming-packages.ha_token 183 chars jellyfin-now-playing.api_key 32 chars ledmatrix-weather.api_key 32 chars on-air.mqtt_password 8 chars youtube.api_key 20 chars youtube-stats.api_key 39 chars A GitHub token and a Home Assistant long-lived token among them. Anything on that LAN could read them. The x-secret masking the plugin config endpoints use does not reach here: this route never consults a schema, and core keys such as github.api_token have no schema to carry the marker. Several of the fields above *are* tagged x-secret in their plugin's schema and were still returned in full, which is what rules out the schema route as the fix for this endpoint. Credential-named fields are now blanked. Matching on the name is blunt, and for a whole-config dump that is the right default: anything named like a credential should not leave the process, and a new plugin adding a differently-shaped secret is covered without anyone remembering to tag it. Blanked rather than removed, and safe to blank: POST /config/main merges into the freshly loaded config and writes only the keys it was given, so a client that round-trips this response cannot erase a secret it never saw. The web API suites confirm it -- 81 passing, unchanged. On the test that matters: the first version of this suite exercised the two helpers and nothing else, and reverting the single line that wires the redactor into the route passed all thirty of them. A property asserted on a helper is not a property asserted on the endpoint, and it is the endpoint that is exposed to the network. The added test goes through the view function, and it does fail on that revert. This also corrects an earlier claim of mine. I reported that GET /api/v3/config did not expose these values; that path 404s, so the check proved nothing. The real route is /config/main and it exposed all of them. --- test/test_config_main_redacts_secrets.py | 143 +++++++++++++++++++++++ web_interface/blueprints/api_v3.py | 43 ++++++- 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 test/test_config_main_redacts_secrets.py diff --git a/test/test_config_main_redacts_secrets.py b/test/test_config_main_redacts_secrets.py new file mode 100644 index 00000000..15172df2 --- /dev/null +++ b/test/test_config_main_redacts_secrets.py @@ -0,0 +1,143 @@ +"""GET /config/main must not hand out credentials. + +The endpoint returned the raw config to anyone who could reach the port, and +this web interface has no authentication of any kind. Measured against a live +rig, an unauthenticated request returned: + + github.api_token 40 chars + incoming-packages.ha_token 183 chars + jellyfin-now-playing.api_key 32 chars + ledmatrix-weather.api_key 32 chars + on-air.mqtt_password 8 chars + youtube.api_key 20 chars + youtube-stats.api_key 39 chars + +A GitHub token and a Home Assistant long-lived token among them. + +The x-secret masking the plugin config endpoints use does not apply here: this +endpoint never consults a schema, and core keys such as github.api_token have +no schema to carry the marker. Several of those fields *are* tagged x-secret in +their plugin's schema and were still returned in full, which is what makes the +schema route the wrong one to rely on for this endpoint. + +Matching on field name is blunt. For a whole-config dump it is the right +default: anything named like a credential should not leave the process, and a +new plugin that adds a differently-shaped secret is covered without anyone +remembering to tag it. +""" +import pytest + +from web_interface.blueprints.api_v3 import ( + _looks_like_a_credential, + _redact_credentials, +) + + +@pytest.mark.parametrize("name", [ + "password", "mqtt_password", "opensky_password", "passwd", + "api_key", "apikey", "API_KEY", "flightaware_api_key", + "token", "ha_token", "api_token", "access_token", + "secret", "client_secret", "spotify_client_secret", + "access_key", "private_key", +]) +def test_credential_names_are_recognised(name): + assert _looks_like_a_credential(name) + + +@pytest.mark.parametrize("name", [ + "timezone", "city", "brightness", "enabled", "update_interval", + "favorite_teams", "display_duration", "keyword", +]) +def test_ordinary_names_are_left_alone(name): + assert not _looks_like_a_credential(name) + + +def test_the_measured_leak_is_closed(): + """The exact shape taken off the rig.""" + config = { + "github": {"api_token": "ghp_" + "x" * 36}, + "incoming-packages": {"ha_token": "y" * 183, "enabled": True}, + "jellyfin-now-playing": {"api_key": "z" * 32}, + "on-air": {"mqtt_password": "hunter22"}, + "youtube": {"api_key": "k" * 20}, + "timezone": "America/New_York", + } + out = _redact_credentials(config) + assert out["github"]["api_token"] == "" + assert out["incoming-packages"]["ha_token"] == "" + assert out["jellyfin-now-playing"]["api_key"] == "" + assert out["on-air"]["mqtt_password"] == "" + assert out["youtube"]["api_key"] == "" + # Everything else survives, or the config editor breaks. + assert out["timezone"] == "America/New_York" + assert out["incoming-packages"]["enabled"] is True + + +def test_nested_and_listed_credentials_are_reached(): + config = {"a": {"b": {"c": {"password": "p"}}}, + "feeds": [{"name": "x", "api_key": "k"}, {"name": "y"}]} + out = _redact_credentials(config) + assert out["a"]["b"]["c"]["password"] == "" + assert out["feeds"][0]["api_key"] == "" + assert out["feeds"][0]["name"] == "x" + + +def test_the_original_is_not_mutated(): + """The caller holds the live config; redaction must not edit it in place.""" + config = {"github": {"api_token": "keepme"}} + _redact_credentials(config) + assert config["github"]["api_token"] == "keepme" + + +def test_a_credential_shaped_container_is_still_walked(): + """`secrets: {...}` is a section name, not a value to blank.""" + config = {"secrets": {"api_key": "k", "note": "keep"}} + out = _redact_credentials(config) + assert out["secrets"]["api_key"] == "" + assert out["secrets"]["note"] == "keep" + + +def test_non_dict_input_passes_through(): + assert _redact_credentials("plain") == "plain" + assert _redact_credentials(7) == 7 + assert _redact_credentials(None) is None + + +def test_the_endpoint_itself_redacts(): + """Through the view function, not the helper. + + The helper tests above all passed with the route still returning + `config` -- reverting the one line that calls the redactor changed + nothing, because nothing exercised the route. A property asserted on a + helper is not a property asserted on the endpoint, and it is the endpoint + that is exposed to the network. + """ + import json as _json + from unittest.mock import MagicMock + + import flask + + from web_interface.blueprints import api_v3 as mod + + raw = {"github": {"api_token": "ghp_secret_value"}, + "timezone": "America/New_York"} + + manager = MagicMock() + manager.load_config.return_value = raw + previous = getattr(mod.api_v3, "config_manager", None) + mod.api_v3.config_manager = manager + + app = flask.Flask(__name__) + try: + with app.test_request_context("/config/main"): + response = mod.get_main_config() + payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data) + finally: + mod.api_v3.config_manager = previous + + data = payload["data"] + assert data["github"]["api_token"] == "", ( + "the endpoint returned the token; the redactor is not wired in") + assert data["timezone"] == "America/New_York" + # And the config the manager handed over is untouched. + assert raw["github"]["api_token"] == "ghp_secret_value" diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 984637e8..f183f452 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -262,15 +262,54 @@ def _stop_display_service(): result['status'] = status return result +#: Field names whose value is a credential. Matched by name because this +#: endpoint returns the whole config, core keys included, and core config has +#: no schema to carry x-secret markers. +_CREDENTIAL_NAME_PARTS = ("password", "passwd", "secret", "token", "api_key", + "apikey", "access_key", "private_key", "client_secret") + + +def _looks_like_a_credential(name: str) -> bool: + lowered = name.lower() + return any(part in lowered for part in _CREDENTIAL_NAME_PARTS) + + +def _redact_credentials(value): + """A copy of `value` with credential-named fields blanked. + + /config/main returned the raw config to anyone who could reach the port, + and this interface has no authentication. On one rig that meant a 40-char + GitHub token, a 183-char Home Assistant token and five API keys were + readable by anything on the LAN. + + The x-secret masking used by the plugin config endpoints does not help + here: this endpoint never consults a schema, and core keys such as + github.api_token have no schema to mark. Matching on the field name is + blunt, but for a whole-config dump the right default is that anything + named like a credential does not leave the process. + + Blanked rather than removed, and safe to blank: POST /config/main merges + into the loaded config and only writes the keys it was given, so a client + that round-trips this response cannot erase a secret it never saw. + """ + if isinstance(value, dict): + return {k: ("" if _looks_like_a_credential(k) and not isinstance(v, (dict, list)) + else _redact_credentials(v)) + for k, v in value.items()} + if isinstance(value, list): + return [_redact_credentials(item) for item in value] + return value + + @api_v3.route('/config/main', methods=['GET']) def get_main_config(): - """Get main configuration""" + """Get main configuration, with credentials redacted.""" try: if not api_v3.config_manager: return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 config = api_v3.config_manager.load_config() - return jsonify({'status': 'success', 'data': config}) + return jsonify({'status': 'success', 'data': _redact_credentials(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