From 46eccbb237ece90c260606b74e0b08f6a274066e Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 11 Jun 2026 13:27:03 -0700 Subject: [PATCH] =?UTF-8?q?#852:=20gate=20the=20WebSocket=20handshake=20?= =?UTF-8?q?=E2=80=94=20close=20the=20launch-PIN/login=20bypass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/security/ws_gate.py | 39 ++++++++++++++++ tests/test_ws_connect_gate.py | 84 +++++++++++++++++++++++++++++++++++ web_server.py | 31 +++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 core/security/ws_gate.py create mode 100644 tests/test_ws_connect_gate.py diff --git a/core/security/ws_gate.py b/core/security/ws_gate.py new file mode 100644 index 00000000..cab092c6 --- /dev/null +++ b/core/security/ws_gate.py @@ -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"] diff --git a/tests/test_ws_connect_gate.py b/tests/test_ws_connect_gate.py new file mode 100644 index 00000000..d861410d --- /dev/null +++ b/tests/test_ws_connect_gate.py @@ -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 diff --git a/web_server.py b/web_server.py index b575e7d0..a8163ea4 100644 --- a/web_server.py +++ b/web_server.py @@ -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')