soulsync/tests/test_launch_lock_gate.py
BoulderBadgeDad 66724186b1 Security: enforce the launch PIN server-side, not just a client overlay (#832)
Beckid: the admin launch PIN was a CLIENT-SIDE overlay only. `launch_pin_required`
just told the frontend to draw a fixed div over the app — removing it (Safari
"Hide Distracting Items", devtools, or any non-browser client like curl) gave
full unauthenticated access to every /api/* endpoint, because the server never
checked it. Anyone who reverse-proxies SoulSync publicly was wide open.

Fix: a before_request gate (_enforce_launch_pin) that rejects every request from
an unverified session while security.require_pin_on_launch is on. The decision
is a pure, unit-tested helper (core/security/launch_lock.request_is_locked) so
the allow/deny matrix can't silently regress. Allowed while locked: the page
shell + static assets, the unlock flow (current/list/select/verify/reset/logout),
and the public REST API /api/v1/ (its own @require_api_key governs it) — EXCEPT
/api/v1/api-keys-internal*, the "no auth required" key-management endpoints,
which stay locked so an attacker can't mint an API key and walk in the side door.
Everything else (data, settings, profile create/edit/delete/set-pin, socket.io)
is blocked.

A blocked top-level browser navigation (deep link / refresh on a sub-page like
/dashboard) is redirected to the root lock screen instead of dumping raw JSON —
detected via Sec-Fetch-Mode: navigate / Accept: text/html (is_html_navigation).
Programmatic fetch/XHR still get the JSON 401 so the frontend can react.

Also fixed the verified flag: get_current_profile POPPED launch_pin_verified
(one page load), but an enforced gate needs it to persist — now READ, so
verification lasts the session (until logout/expiry). No-ops entirely when
require_pin_on_launch is off (default).

Tests: full allow/deny matrix + navigation detection. 20 gate tests + 232
profile/security tests pass.
2026-06-09 22:19:14 -07:00

115 lines
4.7 KiB
Python

"""Server-side launch-PIN gate (#832, Beckid).
The admin PIN was a client-side overlay only; removing the div gave full API
access. request_is_locked() is the pure allow/deny decision the before_request
gate uses. These pin the exact matrix so the lock can't silently regress to
'advisory'.
"""
from __future__ import annotations
import pytest
from core.security.launch_lock import request_is_locked
def L(path, method='GET', require_pin=True, pin_verified=False):
return request_is_locked(path, method, require_pin=require_pin, pin_verified=pin_verified)
# ── gate disabled / already verified → never locks ──────────────────────────
def test_no_lock_when_require_pin_off():
# The default config: nothing is gated.
assert L('/api/downloads/start', 'POST', require_pin=False) is False
assert L('/api/settings', 'POST', require_pin=False) is False
def test_no_lock_when_session_verified():
assert L('/api/downloads/start', 'POST', pin_verified=True) is False
assert L('/api/v1/api-keys-internal/generate', 'POST', pin_verified=True) is False
# ── locked session blocks the app ───────────────────────────────────────────
@pytest.mark.parametrize('path,method', [
('/api/watchlist/artists', 'GET'),
('/api/downloads/start', 'POST'),
('/api/settings', 'POST'),
('/api/wishlist/tracks', 'GET'),
('/api/library/artists', 'GET'),
('/socket.io/', 'GET'),
])
def test_locked_blocks_data_and_action_endpoints(path, method):
assert L(path, method) is True
def test_locked_blocks_profile_mutation():
# Creating/editing/deleting profiles or setting PINs must NOT be reachable
# pre-auth — else an attacker mints an admin or rewrites the PIN.
assert L('/api/profiles', 'POST') is True # create
assert L('/api/profiles/2', 'PUT') is True # edit
assert L('/api/profiles/2', 'DELETE') is True # delete
assert L('/api/profiles/1/set-pin', 'POST') is True
def test_locked_blocks_internal_key_minting():
# The documented "no auth required" key-management endpoints are the
# secondary bypass: mint a key, walk in via the public API. Must be locked.
assert L('/api/v1/api-keys-internal', 'GET') is True
assert L('/api/v1/api-keys-internal/generate', 'POST') is True
assert L('/api/v1/api-keys-internal/revoke/abc', 'DELETE') is True
# ── locked session still allows the unlock flow + shell ─────────────────────
@pytest.mark.parametrize('path', ['/', '/static/init.js', '/static/dist/app.js', '/favicon.ico'])
def test_locked_allows_page_shell_and_assets(path):
assert L(path) is False
def test_locked_allows_unlock_flow():
assert L('/api/profiles/current', 'GET') is False
assert L('/api/profiles', 'GET') is False # picker list
assert L('/api/profiles/select', 'POST') is False
assert L('/api/profiles/verify-launch-pin', 'POST') is False
assert L('/api/profiles/reset-pin-via-credential', 'POST') is False
assert L('/api/profiles/logout', 'POST') is False
def test_locked_allows_keyauthed_public_api():
# The public REST API carries its own @require_api_key, so a launch-locked
# UI must not break a legitimate headless key holder.
assert L('/api/v1/search', 'GET') is False
assert L('/api/v1/playlists', 'GET') is False
def test_method_matters_for_shared_paths():
# GET /api/profiles is the picker (allowed); POST /api/profiles is create
# (blocked). Same path, opposite verdicts.
assert L('/api/profiles', 'GET') is False
assert L('/api/profiles', 'POST') is True
# ── blocked navigations bounce to the lock screen, not JSON (#832 follow-up) ──
from core.security.launch_lock import is_html_navigation # noqa: E402
def test_browser_navigation_is_detected():
# Address-bar / link / refresh — gets redirected to the root lock screen.
assert is_html_navigation('GET', 'text/html,application/xhtml+xml,*/*', '') is True
assert is_html_navigation('GET', '*/*', 'navigate') is True
def test_programmatic_fetch_is_not_navigation():
# fetch()/XHR want JSON so the frontend can react to the 401.
assert is_html_navigation('GET', '*/*', 'cors') is False
assert is_html_navigation('GET', 'application/json', 'same-origin') is False
assert is_html_navigation('GET', '', '') is False
def test_non_get_is_never_navigation():
# A programmatic POST/DELETE always gets JSON, never a redirect.
assert is_html_navigation('POST', 'text/html', 'navigate') is False
assert is_html_navigation('DELETE', 'text/html', '') is False