diff --git a/web_server.py b/web_server.py index b3d47be7..24d55d90 100644 --- a/web_server.py +++ b/web_server.py @@ -17,6 +17,7 @@ import re import sqlite3 import types import collections +import functools from pathlib import Path from urllib.parse import quote, urljoin, urlparse @@ -312,6 +313,32 @@ def get_current_profile_id() -> int: except AttributeError: return 1 + +def admin_only(view_fn): + """Restrict a Flask view to the admin profile (profile_id == 1). + + Settings-class endpoints expose / mutate service tokens, OAuth + secrets, and API keys. Non-admin profiles must not see them. + + NOTE on the underlying auth model: `get_current_profile_id()` + defaults to 1 (admin) when no session is present, which means + single-admin / no-multi-profile installs have no actual gate here — + any request from the local network is treated as admin. This + decorator's job is to gate non-admin profiles in MULTI-profile + setups, not to authenticate the network. The "trust local network" + posture is the project's existing model; tightening it (real auth + on every request) is out of scope for this decorator. + """ + @functools.wraps(view_fn) + def wrapper(*args, **kwargs): + if get_current_profile_id() != 1: + return jsonify({ + "success": False, + "error": "Admin access required", + }), 403 + return view_fn(*args, **kwargs) + return wrapper + # ── Per-profile Spotify client cache ── _profile_spotify_clients = {} # profile_id -> SpotifyClient _profile_spotify_lock = threading.Lock() @@ -6286,6 +6313,7 @@ def revoke_api_key_internal(key_id): @app.route('/api/settings', methods=['GET', 'POST']) +@admin_only def handle_settings(): global tidal_client # Declare that we might modify the global instance if not config_manager: @@ -6584,6 +6612,7 @@ def hydrabase_send(): return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/settings/log-level', methods=['GET', 'POST']) +@admin_only def handle_log_level(): """Get or set the application log level""" from utils.logging_config import set_log_level, get_current_log_level @@ -7443,6 +7472,7 @@ def test_connection_endpoint(): @app.route('/api/settings/config-status', methods=['GET']) +@admin_only def settings_config_status_endpoint(): """Return per-service config state for the Settings → Connections page. Drives the green/yellow header gradient. No API calls — just config reads. @@ -7516,6 +7546,7 @@ def _run_single_verify(service: str): @app.route('/api/settings/verify', methods=['POST']) +@admin_only def settings_verify_endpoint(): """Run connection verification for one or more services. diff --git a/webui/static/helper.js b/webui/static/helper.js index 1df719bb..be8e1a62 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3446,6 +3446,7 @@ const WHATS_NEW = { { date: 'Unreleased — 2.4.1 dev cycle' }, { title: 'Lock Down Socket.IO CORS', desc: 'socket.io was accepting websocket connections from any origin (cors=*). now defaults to same-origin only. if your websocket fails after updating, the server logs a clear warning with the rejected origin — add it to settings → security → allowed websocket origins.', page: 'settings' }, { title: 'Faster Docker Startup — yt-dlp Pinned', desc: 'docker startup used to run `pip install -U yt-dlp` on every container start. removed that — yt-dlp is now pinned in requirements.txt so startup is fast and reproducible. tradeoff: youtube fixes ship via soulsync releases now instead of next container restart.' }, + { title: 'Settings Endpoints: Admin-Only', desc: 'the /api/settings endpoints (read, write, log-level, config-status, verify) had no auth gate — any logged-in profile could read or change service tokens, oauth secrets, api keys. now admin-only. single-admin setups (no multi-profile config) work transparently as before.', page: 'settings' }, ], '2.4.0': [ // --- April 26, 2026 — Search & Artists unification + reorganize queue ---