From 01b7d50311ed84b6d4062a306a5c518e02c00312 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:01:01 -0700 Subject: [PATCH] Gate /api/settings endpoints behind admin profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #370 (reported by JohnBaumb). The /api/settings endpoint and three siblings (/log-level, /config-status, /verify) had no auth check — any logged-in profile could read or modify service tokens, OAuth secrets, and API keys. Cin's "minimum" suggestion from the issue: gate to admin profile. Added an `admin_only` decorator near `get_current_profile_id` that returns 403 when the current profile isn't admin (id=1). Applied to all four endpoints. Auth model note (documented in the decorator docstring): SoulSync's existing model is "trust local network" — single-admin / no-multi- profile installs default `get_current_profile_id()` to 1, so the gate is a no-op for solo users. The decorator is meaningful in multi-profile setups where non-admin sessions exist. Tightening to real per-request auth is out of scope. Did NOT consolidate with api/settings.py (Cin's "better" suggestion): that endpoint uses API-key auth (for external tools), the web_server.py copy uses session/profile auth (for the web UI). Different consumers, different auth models — merging would break one or the other. 603 tests pass. --- web_server.py | 31 +++++++++++++++++++++++++++++++ webui/static/helper.js | 1 + 2 files changed, 32 insertions(+) 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 ---