mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 07:15:09 +00:00
fix(web): keep exception messages out of API responses (py/stack-trace-exposure)
CodeQL had ~40 open py/stack-trace-exposure alerts on main. Almost all
flowed through describe_exception(), which returned "TypeName: message"
(redacted, capped); the rest through _run_systemctl_command's str(err),
WiFiManager's `return False, str(e)`, unit_refresh's f-strings and two
str(e)/f"{err}" messages in api_v3/__init__.py.
describe_exception() now returns a reason code -- the type, plus the
errno symbol for an OSError ("OSError:EIO", "PermissionError:EACCES") --
and logs the redacted message itself. That keeps what #538 wanted (a
failing disk still says EIO in the response) without quoting paths,
URLs or library internals, and fixes every call site at once; the
test_no_api_v3_handler_discards_its_exception policy still holds.
Service results: _get_display_service_status returns active/returncode
only, and the on-demand start/stop `service` result keeps
returncode/active/started/status but drops systemctl stdout/stderr
(logged on failure). Nothing in web_interface/static, the templates or
the MQTT bridge reads those fields. The Starlark SIGKILL-restart error
no longer returns systemctl stderr as `details`.
WiFi, unit-refresh, config-save and plugin-removal failures now say
what failed with the reason code and point at the log. display.py is
untouched (draft #773 edits it).
Tests: test_api_v3_no_exception_text.py drives one route per affected
file with a marker in the exception message and asserts it never
reaches the body; all 13 fail on origin/main, and targeted mutations
(drop the service filter, put stderr back, str(e) in WiFiManager,
{e} in unit_refresh, {install_err} in system.py, message back in
describe_exception) each fail at least one. Tests that asserted the old
message-in-details contract now assert the reason code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""No API response carries an exception's message (CodeQL py/stack-trace-exposure).
|
||||
|
||||
One representative route per file that had open alerts. Each forces a failure
|
||||
whose message holds a marker and asserts the marker is nowhere in the body:
|
||||
the message goes to the log, the client gets a fixed message plus a reason
|
||||
code (describe_exception: the type, and the errno for an OSError).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
LEAK = "LEAKED-/home/pi/secret token=abc123"
|
||||
API = "web_interface.blueprints.api_v3"
|
||||
|
||||
|
||||
def _assert_no_leak(response):
|
||||
body = response.get_data(as_text=True)
|
||||
assert "LEAKED" not in body, body
|
||||
assert "abc123" not in body, body
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def test_display_service_status_drops_systemctl_output(api_v3_module, api_v3_client,
|
||||
monkeypatch):
|
||||
"""display.py: the on-demand routes return the service status verbatim."""
|
||||
api_v3_module.api_v3.cache_manager.get.return_value = None
|
||||
monkeypatch.setattr(f"{API}.display.display_state.read_state", lambda: None)
|
||||
with patch(f"{API}.subprocess.run", side_effect=OSError(13, LEAK)):
|
||||
body = _assert_no_leak(api_v3_client.get("/api/v3/display/on-demand/status"))
|
||||
assert body["data"]["service"] == {"active": False, "returncode": -1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("helper", ["_ensure_display_service_running",
|
||||
"_stop_display_service"])
|
||||
def test_service_results_keep_returncode_but_not_output(api_v3_module, helper):
|
||||
"""display.py start/stop: returncode/active/started stay, stdout/stderr go."""
|
||||
failed = MagicMock(returncode=1, stdout=LEAK, stderr=LEAK)
|
||||
with patch(f"{API}.subprocess.run", return_value=failed):
|
||||
result = getattr(api_v3_module, helper)()
|
||||
assert "LEAKED" not in json.dumps(result)
|
||||
assert result["returncode"] == 1 and result["active"] is False
|
||||
assert "stdout" not in result and "stderr" not in result
|
||||
|
||||
|
||||
def test_wifi_connect_failure(api_v3_client):
|
||||
"""wifi.py: a raising connect, and the attempt /wifi/status reports after."""
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
cls.return_value._is_ap_mode_active.return_value = False
|
||||
cls.return_value.connect_to_network.side_effect = RuntimeError(LEAK)
|
||||
body = _assert_no_leak(api_v3_client.post(
|
||||
"/api/v3/wifi/connect", json={"ssid": "HomeNet", "password": "pw"}))
|
||||
assert body["details"] == "RuntimeError"
|
||||
cls.return_value.get_wifi_status.return_value = MagicMock(
|
||||
connected=False, ssid=None, ip_address=None, signal=0, ap_mode_active=False)
|
||||
cls.return_value.config = {}
|
||||
status = _assert_no_leak(api_v3_client.get("/api/v3/wifi/status"))
|
||||
assert status["data"]["last_connect_attempt"]["message"] == (
|
||||
"Failed to connect to network (RuntimeError)")
|
||||
|
||||
|
||||
def test_wifi_manager_messages_carry_no_exception_text():
|
||||
"""src/wifi_manager.py: its (success, message) is what the wifi routes return."""
|
||||
from src.wifi_manager import WiFiManager
|
||||
manager = WiFiManager.__new__(WiFiManager) # no __init__: no host access
|
||||
manager.get_wifi_status = MagicMock(side_effect=OSError(5, LEAK))
|
||||
success, message = manager.disconnect_from_network()
|
||||
assert success is False
|
||||
assert "LEAKED" not in message and "OSError" in message
|
||||
|
||||
|
||||
def test_system_action_exception(api_v3_client):
|
||||
"""system.py: execute_system_action's catch-all."""
|
||||
with patch("subprocess.run", side_effect=OSError(5, LEAK)):
|
||||
body = _assert_no_leak(api_v3_client.post(
|
||||
"/api/v3/system/action", json={"action": "stop_display"}))
|
||||
assert body["details"] == "OSError:EIO"
|
||||
|
||||
|
||||
def test_calendar_registration_failure(api_v3_client, tmp_path, monkeypatch):
|
||||
"""plugin_calendar.py: the auth script could not be run."""
|
||||
plugin_dir = tmp_path / "calendar"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "credentials.json").write_text("{}", encoding="utf-8")
|
||||
(plugin_dir / "calendar_registration.py").write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(f"{API}._calendar_plugin_dir", lambda: plugin_dir)
|
||||
with patch(f"{API}.subprocess.run", side_effect=OSError(13, LEAK)):
|
||||
body = _assert_no_leak(api_v3_client.post(
|
||||
"/api/v3/plugins/calendar/authenticate", json={"code": "x"}))
|
||||
assert "EACCES" in body["message"]
|
||||
|
||||
|
||||
def test_health_failure(api_v3_client, monkeypatch):
|
||||
"""misc.py: get_health's catch-all."""
|
||||
def boom():
|
||||
raise RuntimeError(LEAK)
|
||||
monkeypatch.setattr(f"{API}.misc._get_display_service_status", boom)
|
||||
body = _assert_no_leak(api_v3_client.get("/api/v3/health"))
|
||||
assert body["details"] == "RuntimeError"
|
||||
|
||||
|
||||
def test_config_route_failure(api_v3_module, api_v3_client):
|
||||
"""error_handler.py: create_error_response, as config.py's routes use it."""
|
||||
api_v3_module.api_v3.config_manager.load_config.side_effect = RuntimeError(LEAK)
|
||||
body = _assert_no_leak(api_v3_client.get("/api/v3/config/schedule"))
|
||||
assert body["details"] == "RuntimeError"
|
||||
|
||||
|
||||
def test_plugin_route_failure(api_v3_module, api_v3_client):
|
||||
"""plugins.py: an unhandled error in a plugin route."""
|
||||
api_v3_module.api_v3.plugin_catalog.get_all_plugin_info.side_effect = RuntimeError(LEAK)
|
||||
body = _assert_no_leak(api_v3_client.get("/api/v3/plugins/installed"))
|
||||
assert body["details"] == "RuntimeError"
|
||||
|
||||
|
||||
def test_starlark_route_failure(api_v3_client):
|
||||
"""starlark.py: one of its catch-alls."""
|
||||
with patch(f"{API}._get_starlark_plugin", side_effect=RuntimeError(LEAK)):
|
||||
body = _assert_no_leak(api_v3_client.get("/api/v3/starlark/status"))
|
||||
assert body["details"] == "RuntimeError"
|
||||
|
||||
|
||||
def test_unit_refresh_failure(monkeypatch):
|
||||
"""system.py git_pull: perform_core_update appends unit_refresh's message."""
|
||||
from web_interface import unit_refresh
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise RuntimeError(LEAK)
|
||||
monkeypatch.setattr(unit_refresh, "stale_units", boom)
|
||||
result = unit_refresh.refresh_after_update()
|
||||
assert result["status"] == unit_refresh.FAILED
|
||||
assert "LEAKED" not in result["message"]
|
||||
|
||||
|
||||
def test_install_base_requirements_failure(api_v3_client):
|
||||
"""system.py: a pip install that could not start, in the action's output."""
|
||||
with patch(f"{API}.system._pip_install_requirements", side_effect=OSError(5, LEAK)):
|
||||
body = _assert_no_leak(api_v3_client.post(
|
||||
"/api/v3/system/action", json={"action": "install_base_requirements"}))
|
||||
assert "Failed: OSError:EIO" in body["output"]
|
||||
Reference in New Issue
Block a user