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 @@