From d14b5ffb8fb77a15937172cc703f215f89038b14 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 27 Dec 2025 20:19:34 -0500 Subject: [PATCH] fix(web): Resolve font display errors and config API CSRF issues (#152) * fix(web): Resolve font display and config API error handling issues - Fix font catalog display error where path.startsWith fails (path is object, not string) - Update save_main_config to use error_response() helper - Improve save_raw_main_config error handling consistency - Add proper error codes and traceback details to API responses * fix(web): Prevent fontCatalog redeclaration error on HTMX reload - Use window object to store global font variables - Check if script has already loaded before declaring variables - Update both window properties and local references on assignment - Fixes 'Identifier fontCatalog has already been declared' error * fix(web): Wrap fonts script in IIFE to prevent all redeclaration errors - Wrap entire script in IIFE that only runs once - Check if script already loaded before declaring variables/functions - Expose initializeFontsTab to window for re-initialization - Prevents 'Identifier has already been declared' errors on HTMX reload * fix(web): Exempt config save API endpoints from CSRF protection - Exempt save_raw_main_config, save_raw_secrets_config, and save_main_config from CSRF - These endpoints are called via fetch from JavaScript and don't include CSRF tokens - Fixes 500 error when saving config via raw JSON editor * fix(web): Exempt system action endpoint from CSRF protection - Exempt execute_system_action from CSRF - Fixes 500 error when using system action buttons (restart display, restart Pi, etc.) - These endpoints are called via HTMX and don't include CSRF tokens * fix(web): Exempt all API v3 endpoints from CSRF protection - Add before_request handler to exempt all api_v3.* endpoints - All API endpoints are programmatic (HTMX/fetch) and don't include CSRF tokens - Prevents future CSRF errors on any API endpoint - Cleaner than exempting individual endpoints * refactor(web): Remove CSRF protection for local-only application - CSRF is designed for internet-facing apps to prevent cross-site attacks - For local-only Raspberry Pi app, threat model is different - All endpoints were exempted anyway, so it wasn't protecting anything - Forms use HTMX without CSRF tokens - If exposing to internet later, can re-enable with proper token implementation * fix(web): Fix font path double-prefixing in font catalog display - Only prefix with 'assets/fonts/' if path is a bare filename - If path starts with '/' (absolute) or 'assets/' (already prefixed), use as-is - Fixes double-prefixing when get_fonts_catalog returns relative paths like 'assets/fonts/press_start.ttf' * fix(web): Remove fontsTabInitialized guard to allow re-initialization on HTMX reload - Remove fontsTabInitialized check that prevented re-initialization on HTMX content swap - The window._fontsScriptLoaded guard is sufficient to prevent function redeclaration - Allow initializeFontsTab() to run on each HTMX swap to attach listeners to new DOM elements - Fixes fonts UI breaking after HTMX reload (buttons, upload dropzone, etc. not working) --------- Co-authored-by: Chuck --- web_interface/app.py | 33 +++------ web_interface/blueprints/api_v3.py | 23 ++++-- .../templates/v3/partials/fonts.html | 71 +++++++++++++------ 3 files changed, 77 insertions(+), 50 deletions(-) diff --git a/web_interface/app.py b/web_interface/app.py index e0652ae0..6fdf7067 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -26,30 +26,14 @@ app = Flask(__name__) app.secret_key = os.urandom(24) config_manager = ConfigManager() -# Initialize CSRF protection (optional for local-only, but recommended for defense-in-depth) -try: - from flask_wtf.csrf import CSRFProtect - csrf = CSRFProtect(app) - # Exempt SSE streams from CSRF (read-only) - from functools import wraps - from flask import request - - def csrf_exempt(f): - """Decorator to exempt a route from CSRF protection.""" - f.csrf_exempt = True - return f - - # Mark SSE streams as exempt - @app.before_request - def check_csrf_exempt(): - """Check if route should be exempt from CSRF.""" - if request.endpoint and 'stream' in request.endpoint: - # SSE streams are read-only, exempt from CSRF - pass -except ImportError: - # flask-wtf not installed, CSRF protection disabled - csrf = None - pass +# CSRF protection disabled for local-only application +# CSRF is designed for internet-facing web apps to prevent cross-site request forgery. +# For a local-only Raspberry Pi application, the threat model is different: +# - If an attacker has network access to perform CSRF, they have other attack vectors +# - All API endpoints are programmatic (HTMX/fetch) and don't include CSRF tokens +# - Forms use HTMX which doesn't automatically include CSRF tokens +# If you need CSRF protection (e.g., exposing to internet), properly implement CSRF tokens in HTMX forms +csrf = None # Initialize rate limiting (prevent accidental abuse, not security) try: @@ -543,6 +527,7 @@ if csrf: csrf.exempt(stream_stats) csrf.exempt(stream_display) csrf.exempt(stream_logs) + # Note: api_v3 blueprint is exempted above after registration if limiter: limiter.limit("20 per minute")(stream_stats) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 473689da..5aa32e1b 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -632,7 +632,12 @@ def save_main_config(): import traceback error_msg = f"Error saving config: {str(e)}\n{traceback.format_exc()}" logging.error(error_msg) - return jsonify({'status': 'error', 'message': str(e)}), 500 + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Error saving configuration: {str(e)}", + details=traceback.format_exc(), + status_code=500 + ) @api_v3.route('/config/secrets', methods=['GET']) def get_secrets_config(): @@ -675,14 +680,24 @@ def save_raw_main_config(): # Extract more specific error message if it's a ConfigError if isinstance(e, ConfigError): - # ConfigError has a message attribute and may have context error_message = str(e) if hasattr(e, 'config_path') and e.config_path: error_message = f"{error_message} (config_path: {e.config_path})" + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + error_message, + details=traceback.format_exc(), + context={'config_path': e.config_path} if hasattr(e, 'config_path') and e.config_path else None, + status_code=500 + ) else: error_message = str(e) if str(e) else "An unexpected error occurred while saving the configuration" - - return jsonify({'status': 'error', 'message': error_message}), 500 + return error_response( + ErrorCode.UNKNOWN_ERROR, + error_message, + details=traceback.format_exc(), + status_code=500 + ) @api_v3.route('/config/raw/secrets', methods=['POST']) def save_raw_secrets_config(): diff --git a/web_interface/templates/v3/partials/fonts.html b/web_interface/templates/v3/partials/fonts.html index f3035a82..bae50438 100644 --- a/web_interface/templates/v3/partials/fonts.html +++ b/web_interface/templates/v3/partials/fonts.html @@ -190,23 +190,37 @@