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 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 08:27:50 -04:00
co-authored by Claude Opus 5.5
parent af8dc3940a
commit d8390e35c5
2 changed files with 30 additions and 1 deletions
+14
View File
@@ -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")):
+16 -1
View File
@@ -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: