From d8390e35c585cd4fd79083bda8a54b2568e0907f Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:27:50 -0400 Subject: [PATCH] fix(api): echo only known control-socket reason codes as socket_error The on-demand routes returned ControlError.reason verbatim. Every reason the client raises is a fixed code, but the display's error code arrives over the socket, so map the value onto the known set (transport reasons plus ErrorCode) and report anything else as "other". Resolves CodeQL py/stack-trace-exposure on the on-demand stop/start responses. Co-Authored-By: Claude Opus 5.5 --- test/test_api_v3_on_demand_socket.py | 14 ++++++++++++++ web_interface/blueprints/api_v3/display.py | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/test/test_api_v3_on_demand_socket.py b/test/test_api_v3_on_demand_socket.py index d6769fb1..8498b8bd 100644 --- a/test/test_api_v3_on_demand_socket.py +++ b/test/test_api_v3_on_demand_socket.py @@ -127,6 +127,20 @@ class TestMailboxFallback: assert resp.get_json()["data"]["socket_error"] == "internal" assert len(_mailbox_writes(service["cache"])) == 1 + def test_an_unknown_reason_is_reported_as_other(self, api_v3_client, service): + # Only known codes are echoed back; anything else stays server-side. + with patch(f"{CLIENT}.on_demand_start", + side_effect=control_client.ControlError("/run/secret/path", "x")): + data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"] + assert data["transport"] == "mailbox" + assert data["socket_error"] == "other" + assert len(_mailbox_writes(service["cache"])) == 1 + + def test_every_display_error_code_is_reportable(self): + from web_interface.blueprints.api_v3 import display + codes = {v for k, v in vars(c.ErrorCode).items() if not k.startswith("_")} + assert codes <= set(display._REPORTABLE_SOCKET_REASONS) + def test_stop_falls_back(self, api_v3_client, service): with patch(f"{CLIENT}.on_demand_stop", side_effect=control_client.ControlError("timeout")): diff --git a/web_interface/blueprints/api_v3/display.py b/web_interface/blueprints/api_v3/display.py index 3b38c955..a2fc74ba 100644 --- a/web_interface/blueprints/api_v3/display.py +++ b/web_interface/blueprints/api_v3/display.py @@ -34,6 +34,21 @@ def _cache_manager(): #: older than the socket, Windows, or switched off. Not worth a log line. _QUIET_SOCKET_REASONS = frozenset({'no_socket', 'disabled', 'unsupported'}) +#: Every reason code a response may echo as ``socket_error``: the client's +#: transport reasons plus the display's ErrorCode values. Anything else is +#: reported as ``other``, so no text taken from an exception reaches a reply. +_REPORTABLE_SOCKET_REASONS = ( + 'disabled', 'unsupported', 'no_socket', 'refused', 'timeout', 'closed', + 'bad_response', 'invalid_request', + 'bad_json', 'bad_request', 'message_too_large', 'unsupported_version', + 'unknown_command', 'invalid_args', 'busy', 'forbidden', 'internal', +) + + +def _socket_reason_code(reason): + """``reason`` as one of _REPORTABLE_SOCKET_REASONS, else ``'other'``.""" + return next((code for code in _REPORTABLE_SOCKET_REASONS if code == reason), 'other') + def _deliver_on_demand(payload): """Hand an on-demand request to the display: control socket, else mailbox. @@ -58,7 +73,7 @@ def _deliver_on_demand(payload): control_client.on_demand_stop(payload['request_id']) return 'socket', None except control_client.ControlError as e: - reason = e.reason + reason = _socket_reason_code(e.reason) if reason in _QUIET_SOCKET_REASONS: logger.debug("On-demand %s via the mailbox: %s", payload['action'], e) else: