-
-
Notifications
You must be signed in to change notification settings - Fork 26
consolidate(web): credential exposure, secret loss, and the update path #485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fc6104f
95bd8a1
f0efdaf
d2379a1
ec8591e
cbfb0e0
6642a78
e085379
c7401e1
b8ef658
1cb01c1
f4db8dc
7d8d3a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -58,7 +58,7 @@ def repos(tmp_path): | |||||||||||||||||||||
| def test_branch_with_upstream_uses_a_plain_pull(repos): | ||||||||||||||||||||||
| args, note, error = resolve_pull_command(str(repos)) | ||||||||||||||||||||||
| assert error is None | ||||||||||||||||||||||
| assert args == ['git', 'pull', '--rebase'] | ||||||||||||||||||||||
| assert args == ['git', 'pull', '--rebase', '--autostash'] | ||||||||||||||||||||||
| assert note == '' | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -73,7 +73,7 @@ def test_branch_without_upstream_falls_back_to_origin_branch(repos): | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| args, note, error = resolve_pull_command(str(repos)) | ||||||||||||||||||||||
| assert error is None | ||||||||||||||||||||||
| assert args == ['git', 'pull', '--rebase', 'origin', 'audit'] | ||||||||||||||||||||||
| assert args == ['git', 'pull', '--rebase', '--autostash', 'origin', 'audit'] | ||||||||||||||||||||||
| assert 'audit' in note | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -155,7 +155,7 @@ def test_switching_attaches_tracking_so_pull_needs_no_fallback(repos): | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| args, note, error = resolve_pull_command(str(repos)) | ||||||||||||||||||||||
| assert error is None | ||||||||||||||||||||||
| assert args == ['git', 'pull', '--rebase'] | ||||||||||||||||||||||
| assert args == ['git', 'pull', '--rebase', '--autostash'] | ||||||||||||||||||||||
| assert note == '' | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -200,3 +200,37 @@ def test_stash_option_lets_the_switch_through_and_keeps_the_work(repos): | |||||||||||||||||||||
| assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'other' | ||||||||||||||||||||||
| # The edit is not lost — it is on the stash. | ||||||||||||||||||||||
| assert 'switch to other' in _git('stash', 'list', cwd=repos).stdout | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| class TestInstallerDoesNotBlockTheUpdateButton: | ||||||||||||||||||||||
| """first_time_install.sh chmods scripts that git tracked as 644. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| With core.fileMode true -- the default on Linux -- that leaves five | ||||||||||||||||||||||
| permanently modified tracked files on every machine that ran the | ||||||||||||||||||||||
| installer, and `git pull --rebase` refuses to start: | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| error: cannot pull with rebase: You have unstaged changes. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Tracking them as executable makes the installer's chmod a no-op. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| CHMODDED = [ | ||||||||||||||||||||||
| 'first_time_install.sh', | ||||||||||||||||||||||
| 'start_display.sh', | ||||||||||||||||||||||
| 'stop_display.sh', | ||||||||||||||||||||||
| 'scripts/install/install_service.sh', | ||||||||||||||||||||||
| 'scripts/install/install_web_service.sh', | ||||||||||||||||||||||
| ] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def test_scripts_the_installer_chmods_are_tracked_executable(self): | ||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||
| root = Path(__file__).resolve().parent.parent | ||||||||||||||||||||||
| out = subprocess.run(['git', 'ls-files', '-s', *self.CHMODDED], | ||||||||||||||||||||||
| capture_output=True, text=True, cwd=str(root)).stdout | ||||||||||||||||||||||
| modes = {line.split()[3]: line.split()[0] for line in out.strip().split('\n') if line} | ||||||||||||||||||||||
| non_exec = sorted(f for f, m in modes.items() if m != '100755') | ||||||||||||||||||||||
|
Comment on lines
+229
to
+232
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Fail when an installer target is not tracked.
Assert that Proposed fix modes = {line.split()[3]: line.split()[0] for line in out.strip().split('\n') if line}
+ missing = sorted(set(self.CHMODDED) - set(modes))
+ assert not missing, f"{missing} are not tracked by Git"
non_exec = sorted(f for f, m in modes.items() if m != '100755')📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.16.1)[error] 229-229: (S603) [error] 229-229: Starting a process with a partial executable path (S607) 🤖 Prompt for AI Agents |
||||||||||||||||||||||
| assert not non_exec, ( | ||||||||||||||||||||||
| f"{non_exec} are chmodded by the installer but tracked non-executable, " | ||||||||||||||||||||||
| "so every install leaves the working tree dirty and the update " | ||||||||||||||||||||||
| "button cannot pull") | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| """A check that could not run must not be reported as "up to date". | ||
|
|
||
| check-update returned update_available=False whenever git failed. The banner | ||
| is the only route to the update button, so a checkout git refuses to touch | ||
| looked exactly like a current one -- permanently, and with nothing for the | ||
| user to act on. The usual cause is an install performed as root, after which | ||
| every git command fails with "detected dubious ownership". | ||
| """ | ||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
| from unittest.mock import patch | ||
|
|
||
| import pytest | ||
| from flask import Flask | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | ||
|
|
||
| from web_interface.blueprints import api_v3 as mod # noqa: E402 | ||
| from web_interface.blueprints.api_v3 import api_v3 # noqa: E402 | ||
|
|
||
| DUBIOUS = ("fatal: detected dubious ownership in repository at " | ||
| "'/home/pi/LEDMatrix'\nTo add an exception for this directory, call:\n" | ||
| "\tgit config --global --add safe.directory /home/pi/LEDMatrix\n") | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def client(): | ||
| app = Flask(__name__) | ||
| app.config['TESTING'] = True | ||
| app.register_blueprint(api_v3, url_prefix='/api/v3') | ||
| mod._update_check_cache['result'] = None | ||
| mod._update_check_cache['ts'] = 0 | ||
| return app.test_client() | ||
|
|
||
|
|
||
| def _fetch_fails(stderr: bytes): | ||
| def fake_run(args, **kwargs): | ||
| if args[:2] == ['git', 'fetch']: | ||
| return subprocess.CompletedProcess(args, 1, stdout=b'', stderr=stderr) | ||
| return subprocess.CompletedProcess(args, 0, stdout='', stderr='') | ||
| return fake_run | ||
|
|
||
|
|
||
| class TestFailedCheckIsNotSilence: | ||
| def test_dubious_ownership_is_reported_not_swallowed(self, client): | ||
| with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())): | ||
| data = client.get('/api/v3/system/check-update').get_json() | ||
| assert data['check_failed'] is True, ( | ||
| "a git failure was reported as a successful 'no update' check") | ||
| assert data['update_available'] is False | ||
|
|
||
| def test_the_message_tells_the_user_what_to_do(self, client): | ||
| with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())): | ||
| data = client.get('/api/v3/system/check-update').get_json() | ||
| assert 'chown' in data['error'], ( | ||
| "dubious ownership is unactionable without the fix command") | ||
| assert 'root' in data['error'] | ||
|
|
||
| def test_an_ordinary_git_failure_still_surfaces(self, client): | ||
| with patch.object(mod.subprocess, 'run', | ||
| _fetch_fails(b'fatal: some other git problem\n')): | ||
| data = client.get('/api/v3/system/check-update').get_json() | ||
| assert data['check_failed'] is True | ||
| assert 'some other git problem' in data['error'] | ||
|
|
||
| def test_offline_reads_as_offline(self, client): | ||
| with patch.object(mod.subprocess, 'run', | ||
| _fetch_fails(b'fatal: could not resolve host: github.com\n')): | ||
| data = client.get('/api/v3/system/check-update').get_json() | ||
| assert 'Could not reach GitHub' in data['error'] | ||
|
|
||
|
|
||
| class TestSuccessPathUnchanged: | ||
| def test_up_to_date_carries_no_failure_flag(self, client): | ||
| def fake_run(args, **kwargs): | ||
| if args[:2] == ['git', 'fetch']: | ||
| return subprocess.CompletedProcess(args, 0, stdout=b'', stderr=b'') | ||
| if args[:2] == ['git', 'rev-parse']: | ||
| return subprocess.CompletedProcess(args, 0, stdout='abc123\n', stderr='') | ||
| return subprocess.CompletedProcess(args, 0, stdout='0\n', stderr='') | ||
| with patch.object(mod.subprocess, 'run', fake_run): | ||
| data = client.get('/api/v3/system/check-update').get_json() | ||
| assert data['update_available'] is False | ||
| assert not data.get('check_failed'), "a healthy check must not look like a failure" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Handle list-shaped secrets recursively.
strip_masked_valuesdoes not process lists.mask_all_secret_valuesalso treats a non-empty list as one scalar and returnsSECRET_MASK. A raw secrets value such asaccounts: [{"token": "..."}]therefore loses its response structure.The raw editor cannot update one list item safely. A structured client submission can also retain mask values inside lists. Add recursive list handling and define merge behavior that preserves unchanged list items.
🤖 Prompt for AI Agents