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:
@@ -222,7 +222,7 @@ def _save_config_atomic(config_manager, config_data, create_backup=True):
|
||||
config_manager.save_config(config_data)
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return False, f"Failed to save configuration ({describe_exception(e)})"
|
||||
def _coerce_to_bool(value):
|
||||
"""
|
||||
Coerce a form value to a proper Python boolean.
|
||||
@@ -246,7 +246,11 @@ def _coerce_to_bool(value):
|
||||
return value.lower() in ('true', 'on', '1', 'yes')
|
||||
return False
|
||||
def _get_display_service_status():
|
||||
"""Return status information about the ledmatrix service."""
|
||||
"""Return status information about the ledmatrix service.
|
||||
|
||||
active/returncode only: this goes back in API responses, and systemctl's
|
||||
output (or an exception's text) is logged rather than returned.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['systemctl', 'is-active', 'ledmatrix'],
|
||||
@@ -254,26 +258,18 @@ def _get_display_service_status():
|
||||
text=True,
|
||||
timeout=3
|
||||
)
|
||||
if result.stderr.strip():
|
||||
logger.debug('systemctl is-active ledmatrix: %s', result.stderr.strip())
|
||||
return {
|
||||
'active': result.stdout.strip() == 'active',
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout.strip(),
|
||||
'stderr': result.stderr.strip()
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
'active': False,
|
||||
'returncode': -1,
|
||||
'stdout': '',
|
||||
'stderr': 'timeout'
|
||||
}
|
||||
except Exception as err:
|
||||
return {
|
||||
'active': False,
|
||||
'returncode': -1,
|
||||
'stdout': '',
|
||||
'stderr': str(err)
|
||||
}
|
||||
logger.warning('systemctl is-active ledmatrix timed out')
|
||||
return {'active': False, 'returncode': -1}
|
||||
except Exception:
|
||||
logger.warning('Could not query ledmatrix.service status', exc_info=True)
|
||||
return {'active': False, 'returncode': -1}
|
||||
def _run_systemctl_command(args):
|
||||
"""Run a systemctl command safely."""
|
||||
try:
|
||||
@@ -295,18 +291,26 @@ def _run_systemctl_command(args):
|
||||
'stderr': 'timeout'
|
||||
}
|
||||
except Exception as err:
|
||||
logger.warning('%s failed', ' '.join(args), exc_info=True)
|
||||
return {
|
||||
'returncode': -1,
|
||||
'stdout': '',
|
||||
'stderr': str(err)
|
||||
'stderr': describe_exception(err)
|
||||
}
|
||||
def _public_service_result(result):
|
||||
"""A _run_systemctl_command result fit for a response: no stdout/stderr."""
|
||||
if result.get('returncode') != 0:
|
||||
logger.error('systemctl exited %s: %s', result.get('returncode'),
|
||||
(result.get('stderr') or '').strip())
|
||||
return {k: v for k, v in result.items() if k not in ('stdout', 'stderr')}
|
||||
def _ensure_display_service_running():
|
||||
"""Ensure the ledmatrix display service is running."""
|
||||
status = _get_display_service_status()
|
||||
if status.get('active'):
|
||||
status['started'] = False
|
||||
return status
|
||||
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service'])
|
||||
result = _public_service_result(
|
||||
_run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service']))
|
||||
service_status = _get_display_service_status()
|
||||
result['started'] = result.get('returncode') == 0
|
||||
result['active'] = service_status.get('active')
|
||||
@@ -314,7 +318,8 @@ def _ensure_display_service_running():
|
||||
return result
|
||||
def _stop_display_service():
|
||||
"""Stop the ledmatrix display service."""
|
||||
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service'])
|
||||
result = _public_service_result(
|
||||
_run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service']))
|
||||
status = _get_display_service_status()
|
||||
result['active'] = status.get('active')
|
||||
result['status'] = status
|
||||
@@ -712,7 +717,7 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
|
||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
||||
except Exception as remove_err:
|
||||
_rollback()
|
||||
return False, f"Failed to remove plugin {plugin_id}: {remove_err}"
|
||||
return False, f"Failed to remove plugin {plugin_id} ({describe_exception(remove_err)})"
|
||||
|
||||
if not success:
|
||||
_rollback()
|
||||
|
||||
@@ -983,7 +983,6 @@ def stop_pixlet_editor():
|
||||
'status': 'error',
|
||||
'message': 'Editor force-stopped, but the display could not be '
|
||||
'restarted automatically - start it manually.',
|
||||
'details': (result.get('stderr') or '').strip(),
|
||||
'data': {'running': False}}), 500
|
||||
return jsonify({'status': 'success',
|
||||
'message': 'Editor force-stopped; the display has been '
|
||||
|
||||
@@ -689,7 +689,7 @@ def execute_system_action():
|
||||
logger.warning("install_base_requirements timed out for %s", label)
|
||||
except OSError as install_err:
|
||||
all_ok = False
|
||||
outputs.append(f"== {label} ==\nFailed: {install_err}")
|
||||
outputs.append(f"== {label} ==\nFailed: {describe_exception(install_err)}")
|
||||
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
|
||||
return jsonify({
|
||||
'status': 'success' if all_ok else 'error',
|
||||
@@ -784,11 +784,10 @@ def execute_system_action():
|
||||
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
||||
except Exception as e:
|
||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||
detail = describe_exception(e)
|
||||
resp = {
|
||||
'status': 'error',
|
||||
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details',
|
||||
'details': detail,
|
||||
'message': _sudo_hint_for(str(e)) or 'Action failed; see logs for details',
|
||||
'details': describe_exception(e),
|
||||
}
|
||||
return jsonify(resp), 500
|
||||
@api_v3.route('/system/git-info', methods=['GET'])
|
||||
|
||||
@@ -67,7 +67,7 @@ def _run_background_connect(ssid, password):
|
||||
payload = _connect_result_payload(ssid, success, message)
|
||||
except Exception as e:
|
||||
logger.error("Background WiFi connect failed", exc_info=True)
|
||||
payload = {'status': 'error', 'message': describe_exception(e)}
|
||||
payload = {'status': 'error', 'message': f'Failed to connect to network ({describe_exception(e)})'}
|
||||
_record_connect_result(ssid, payload)
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ def connect_wifi():
|
||||
try:
|
||||
success, message = wifi_manager.connect_to_network(ssid, password)
|
||||
except Exception as e:
|
||||
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
|
||||
_record_connect_result(ssid, {'status': 'error', 'message': f'Failed to connect to network ({describe_exception(e)})'})
|
||||
raise
|
||||
payload = _connect_result_payload(ssid, success, message)
|
||||
_record_connect_result(ssid, payload)
|
||||
|
||||
Reference in New Issue
Block a user