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.
84 lines
2.9 KiB
Python
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
|