#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:
parent
87e5e1fa23
commit
46eccbb237
3 changed files with 154 additions and 0 deletions
39
core/security/ws_gate.py
Normal file
39
core/security/ws_gate.py
Normal 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"]
|
||||
84
tests/test_ws_connect_gate.py
Normal file
84
tests/test_ws_connect_gate.py
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue