Merge pull request #385 from Nezreka/fix/settings-endpoint-no-auth

Gate /api/settings endpoints behind admin profile
This commit is contained in:
BoulderBadgeDad 2026-04-26 20:18:28 -07:00 committed by GitHub
commit e0573729ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 32 additions and 0 deletions

View file

@ -17,6 +17,7 @@ import re
import sqlite3 import sqlite3
import types import types
import collections import collections
import functools
from pathlib import Path from pathlib import Path
from urllib.parse import quote, urljoin, urlparse from urllib.parse import quote, urljoin, urlparse
@ -312,6 +313,32 @@ def get_current_profile_id() -> int:
except AttributeError: except AttributeError:
return 1 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 ── # ── Per-profile Spotify client cache ──
_profile_spotify_clients = {} # profile_id -> SpotifyClient _profile_spotify_clients = {} # profile_id -> SpotifyClient
_profile_spotify_lock = threading.Lock() _profile_spotify_lock = threading.Lock()
@ -6286,6 +6313,7 @@ def revoke_api_key_internal(key_id):
@app.route('/api/settings', methods=['GET', 'POST']) @app.route('/api/settings', methods=['GET', 'POST'])
@admin_only
def handle_settings(): def handle_settings():
global tidal_client # Declare that we might modify the global instance global tidal_client # Declare that we might modify the global instance
if not config_manager: if not config_manager:
@ -6584,6 +6612,7 @@ def hydrabase_send():
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/settings/log-level', methods=['GET', 'POST']) @app.route('/api/settings/log-level', methods=['GET', 'POST'])
@admin_only
def handle_log_level(): def handle_log_level():
"""Get or set the application log level""" """Get or set the application log level"""
from utils.logging_config import set_log_level, get_current_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']) @app.route('/api/settings/config-status', methods=['GET'])
@admin_only
def settings_config_status_endpoint(): def settings_config_status_endpoint():
"""Return per-service config state for the Settings → Connections page. """Return per-service config state for the Settings → Connections page.
Drives the green/yellow header gradient. No API calls just config reads. 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']) @app.route('/api/settings/verify', methods=['POST'])
@admin_only
def settings_verify_endpoint(): def settings_verify_endpoint():
"""Run connection verification for one or more services. """Run connection verification for one or more services.

View file

@ -3446,6 +3446,7 @@ const WHATS_NEW = {
{ date: 'Unreleased — 2.4.1 dev cycle' }, { 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: '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: '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': [ '2.4.0': [
// --- April 26, 2026 — Search & Artists unification + reorganize queue --- // --- April 26, 2026 — Search & Artists unification + reorganize queue ---