Files
LEDMatrix/src/web_interface/api_helpers.py
T
ChuckandClaude Opus 5.5 bcef1957a9 fix(security): refuse unsafe plugin ids, keep secrets private, validate request bodies (#643)
* fix(security): refuse unsafe plugin ids, keep secrets private, validate bodies

- install_from_url and the registry install's manifest rename refuse a
  plugin id that is not a single safe name (no ../ out of plugins_dir).
- Uninstall and config reset refuse core config sections and ids with
  path parts; uninstall of a plugin whose directory is gone still works.
- separate_secrets checks a field's own x-secret marker before recursing,
  so object/array secrets no longer land in config.json.
- Backup restore creates missing secrets/wifi/ytm files with mode 640;
  export skips non-object manifests and no longer collides on same-second
  exports.
- SYSTEM_FONTS includes every bundled font from BUNDLED_FONTS.
- Raw config/secrets saves and validate_request_json require a JSON object.
- A blank max_dynamic_duration_seconds keeps the stored value; other values
  are validated to 30-1800 instead of raising a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): validate the id before install_plugin moves anything; claim backup names atomically

- install_plugin set aside plugins_dir / plugin_id before any id check, so
  "../x" moved a directory outside the plugins dir (the rollback moved it
  back, but only if the install path got that far)
- two exports finishing in the same second could both see a free name and
  the later os.replace destroyed the first archive; the name is now
  claimed with O_EXCL before the archive is swapped in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:24:43 -04:00

153 lines
4.5 KiB
Python

"""
Standardized API response helpers.
Provides consistent API response formatting across all endpoints.
"""
import time
from typing import Any, Optional, Dict, Tuple
from flask import jsonify, request
from src.web_interface.error_handler import create_error_response, create_success_response
from src.web_interface.errors import ErrorCode, WebInterfaceError
def success_response(
data: Any = None,
message: Optional[str] = None,
metadata: Optional[Dict] = None
):
"""
Create a standardized success response.
Args:
data: Response data
message: Optional success message
metadata: Optional metadata (timing, version, etc.)
Returns:
Flask jsonify response
"""
response_data = create_success_response(data, message, metadata)
# Timing is merged into whatever the caller passed, without inventing a
# metadata block for responses that have neither.
enriched = dict(metadata) if metadata is not None else {}
if hasattr(request, 'start_time'):
# request_logging stamps start_time from perf_counter, not the wall clock.
enriched['response_time_ms'] = int((time.perf_counter() - request.start_time) * 1000)
if metadata is not None or enriched:
response_data['metadata'] = enriched
return jsonify(response_data)
def error_response(
error_code: ErrorCode,
message: str,
details: Optional[str] = None,
context: Optional[Dict] = None,
suggested_fixes: Optional[list] = None,
status_code: int = 500
):
"""
Create a standardized error response.
Args:
error_code: Error code
message: Error message
details: Optional detailed error information
context: Optional context dictionary
suggested_fixes: Optional list of suggested fixes
status_code: HTTP status code
Returns:
Flask jsonify response with status code
"""
return create_error_response(
error_code=error_code,
message=message,
details=details,
context=context,
suggested_fixes=suggested_fixes,
status_code=status_code
)
def exception_error_response(
exc: Exception,
error_code: ErrorCode,
*,
with_context: bool = True,
status_code: int = 500
):
"""
error_response() for a caught exception, built by WebInterfaceError.
The message is the code's fixed, user-facing one -- never the exception
text. `details` comes from the exception's own `context` dict when it has
one, and `context` records the exception type. with_context=False leaves
the context out, as the operation-history routes always have.
Args:
exc: The exception being reported
error_code: Error code
with_context: Whether to include the context (exception type)
status_code: HTTP status code
Returns:
Flask jsonify response with status code
"""
error = WebInterfaceError.from_exception(exc, error_code)
return error_response(
error.error_code,
error.message,
details=error.details,
context=error.context if with_context else None,
status_code=status_code
)
def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
"""
Validate request JSON has required fields.
Args:
required_fields: List of required field names
data: Optional data dict (if None, reads from request)
Returns:
Tuple of (data_dict, error_response) or (data_dict, None) if valid
"""
if data is None:
data = request.get_json(silent=True)
if not data:
return None, error_response(
ErrorCode.INVALID_INPUT,
"Request body must be valid JSON",
status_code=400
)
# A JSON array passes the check above, and ``field in data`` then tests
# list membership: ["plugin_id"] "had" every required field and the
# handler's data['plugin_id'] raised TypeError -- a 500, not a 400.
if not isinstance(data, dict):
return None, error_response(
ErrorCode.INVALID_INPUT,
"Request body must be a JSON object",
status_code=400
)
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
return None, error_response(
ErrorCode.INVALID_INPUT,
f"Missing required fields: {', '.join(missing_fields)}",
context={'missing_fields': missing_fields},
status_code=400
)
return data, None