soulsync/tests/test_ws_connect_gate.py
BoulderBadgeDad 46eccbb237 #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.
2026-06-11 13:27:03 -07:00

84 lines
2.9 KiB
Python

"""#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