soulsync/core/security/ws_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

39 lines
1.3 KiB
Python

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