#852: gate the WebSocket handshake — close the launch-PIN/login bypass

The #832 fix enforces the launch PIN / login via a Flask before_request hook, but
that hook does NOT run for the socketio handshake — empirically a normal endpoint
401s while /socket.io/ returns 200 with the gate on. So removing the client overlay
(Safari "Hide Distracting Items", devtools) + opening a socket streams live data
(downloads, logs, dashboard, notifications) completely unauthenticated.

Fix: the socketio connect handler now enforces the same check and returns False
(rejects the connection) when a gate is active and the session isn't verified.
Rejecting connect blocks every downstream WS event (subscribe/join), so all live
data is covered. core/security/ws_gate.is_ws_connection_blocked is the pure seam:
login mode (when on) > launch PIN > open, mirroring the HTTP gate exactly. Fails
OPEN on a config-read error, same as the HTTP gate.

Audited every other surface empirically with the gate on + unauthenticated: SSE
streams, catch-all pages, library/dashboard data, admin endpoints, search,
image-proxy, audio-stream (incl. a /etc/passwd traversal probe) all 401; /api/v1
key-gated. The WebSocket was the only hole.

Tests (10): pure gate logic (login>pin precedence, all on/off combos) + real
socketio.test_client integration — connect rejected when gate on + unauthenticated,
allowed when gate off or PIN verified.
This commit is contained in:
BoulderBadgeDad 2026-06-11 13:27:03 -07:00
parent 87e5e1fa23
commit 46eccbb237
3 changed files with 154 additions and 0 deletions

39
core/security/ws_gate.py Normal file
View file

@ -0,0 +1,39 @@
"""WebSocket access gate (#852).
Flask's ``before_request`` — where the launch-PIN / login gate lives — does NOT
run for the socketio handshake, so an unauthenticated client that removes the
login/PIN overlay (Safari "Hide Distracting Items", devtools, curl) can still open
a socket and receive the live data SoulSync streams over it (downloads, logs,
dashboard, notifications). The connect handler must therefore enforce the same
check the HTTP gate does.
Pure decision so it's unit-testable; the socketio handler injects the live
session/config/header values. Mirrors the HTTP gate precedence exactly: login
mode (when on) replaces the launch PIN.
"""
from __future__ import annotations
def is_ws_connection_blocked(
*,
require_login: bool,
login_authenticated: bool,
require_pin: bool,
pin_verified: bool,
proxy_authed: bool,
) -> bool:
"""True ⇒ reject this WebSocket connection.
- Login mode on must be login-authenticated.
- Else PIN on must have verified the PIN (or be trusted by an auth proxy).
- Neither on open (matches the HTTP gate's no-op default).
"""
if require_login:
return not login_authenticated
if require_pin:
return not (pin_verified or proxy_authed)
return False
__all__ = ["is_ws_connection_blocked"]

View file

@ -0,0 +1,84 @@
"""#852: the socketio handshake bypasses the HTTP launch-PIN/login gate
(before_request doesn't run for it). The connect handler must enforce the same
check, or removing the overlay + opening a socket streams live data unauthenticated."""
from __future__ import annotations
import os
import tempfile
import pytest
from core.security.ws_gate import is_ws_connection_blocked as blocked
# ── pure gate logic ────────────────────────────────────────────────────────
def _b(**kw):
base = dict(require_login=False, login_authenticated=False,
require_pin=False, pin_verified=False, proxy_authed=False)
base.update(kw)
return blocked(**base)
def test_nothing_on_allows():
assert _b() is False
def test_login_on_unauth_blocks():
assert _b(require_login=True) is True
def test_login_on_authed_allows():
assert _b(require_login=True, login_authenticated=True) is False
def test_pin_on_unverified_blocks():
assert _b(require_pin=True) is True
def test_pin_on_verified_allows():
assert _b(require_pin=True, pin_verified=True) is False
def test_pin_on_proxy_authed_allows():
assert _b(require_pin=True, proxy_authed=True) is False
def test_login_takes_precedence_over_pin():
# login on + unauth -> blocked even if the PIN was verified
assert _b(require_login=True, require_pin=True, pin_verified=True, proxy_authed=True) is True
# ── integration: real socketio connect via the gate ────────────────────────
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-wsgate-')
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'w.db')
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
web_server = pytest.importorskip('web_server')
def _pin_on(monkeypatch):
real = web_server.config_manager.get
monkeypatch.setattr(web_server.config_manager, 'get',
lambda k, d=None: True if k == 'security.require_pin_on_launch' else real(k, d))
def test_socket_rejected_when_gate_on_and_unauthenticated(monkeypatch):
_pin_on(monkeypatch)
flask_client = web_server.app.test_client() # no launch_pin_verified
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
assert sio.is_connected() is False # the #852 hole, now closed
def test_socket_allowed_when_gate_off():
flask_client = web_server.app.test_client()
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
assert sio.is_connected() is True
def test_socket_allowed_when_pin_verified(monkeypatch):
_pin_on(monkeypatch)
flask_client = web_server.app.test_client()
with flask_client.session_transaction() as s:
s['launch_pin_verified'] = True
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
assert sio.is_connected() is True

View file

@ -36500,8 +36500,39 @@ def _emit_download_status_loop():
# --- Socket.IO event handlers ---
def _ws_connection_blocked():
"""#852: mirror the HTTP launch-PIN / login gate for the socketio handshake
(before_request doesn't run for it). Fails OPEN on a config-read error, same
as the HTTP gate, so a broken config never wedges every client."""
try:
require_login = _require_login_enabled()
require_pin = bool(config_manager.get('security.require_pin_on_launch', False)) if config_manager else False
if not require_login and not require_pin:
return False
proxy_header = (config_manager.get('security.auth_proxy_header', '') or '') if config_manager else ''
from core.security.auth_proxy import trusted_proxy_user
proxy_authed = bool(trusted_proxy_user(request.headers.get, proxy_header))
from core.security.ws_gate import is_ws_connection_blocked
return is_ws_connection_blocked(
require_login=require_login,
login_authenticated=bool(session.get('login_authenticated', False)),
require_pin=require_pin,
pin_verified=bool(session.get('launch_pin_verified', False)),
proxy_authed=proxy_authed,
)
except Exception as e:
logger.error(f"[WS gate] check failed, allowing (matches HTTP gate fail-open): {e}")
return False
@socketio.on('connect')
def handle_connect():
# #852: the launch-PIN / login gate is a before_request hook, which does NOT
# run for the socketio handshake. Without this, removing the client overlay +
# opening a socket streams live data (downloads/logs/dashboard) unauthenticated.
if _ws_connection_blocked():
logger.warning("Rejected WebSocket connection — access gate active, session not verified (#852)")
return False
logger.info("WebSocket client connected")
@socketio.on('disconnect')