Closes #366 (reported by JohnBaumb). Socket.IO was initialized with `cors_allowed_origins='*'`, accepting WebSocket connections from any origin. A malicious site could open a WS to a user's local SoulSync instance and exfiltrate live progress / toast / activity events. This commit: - Defaults to engineio's same-origin behavior (`cors_allowed_origins=None`), which automatically honors X-Forwarded-Host so reverse proxies that send that header (Caddy / Traefik by default, properly-configured Nginx) work transparently. - Adds a `security.cors_origins` config setting + Settings → Security textarea where users behind unusual proxies / Electron wrappers / cross-origin integrations can whitelist their origin. Accepts comma or newline separated values; `*` on its own line opts back into the legacy wildcard with a startup-warning log. - Logs a clear warning the first time engineio rejects each unique origin, naming the rejected Origin and request Host and pointing users to the settings field. Without this, engineio silently 403s the upgrade and the user just sees a half-broken UI with no clue why. Threadsafe dedup so a hostile origin can't spam logs. Logic lives in `core/socketio_cors.py` (resolver, rejection predictor, dedup logger class, startup-status emitter) — pure functions, no Flask dependency. `web_server.py` adds 23 lines of wiring and imports. Important catch during review: my first pass used `cors_allowed_origins=[]` as the "secure default." Reading engineio's source revealed `[]` actually means "DISABLE CORS HANDLING" (engineio/server.py:202: `if cors_allowed_origins != []:`) — identical security to `'*'`. Fixed to use `None` (engineio's actual same-origin sentinel) and pinned with a regression test that asserts the resolver never returns `[]` for any input shape. Tests: - tests/test_socketio_cors.py — 45 unit tests covering 19 resolver shape cases (None, empty, whitespace, comma, newline, garbage types, lists), the `[]`-must-never-be-returned security regression, 12 rejection prediction cases, X-Forwarded-Host handling, dedup logger behavior, threadsafe race (8 threads × 50 hammers → exactly 1 warning), and startup-status emitter outputs. Frontend: - Settings → Security gains an "Allowed WebSocket Origins" textarea with help text explaining same-origin default + when to add a domain + the `*` opt-out. - helper.js — new '2.4.1' WHATS_NEW block (hidden until version bump) with a chill-voice entry describing the change. Conftest.py left at `'*'` — test environment, no security concern. 598 tests pass.
191 lines
7.7 KiB
Python
191 lines
7.7 KiB
Python
"""Socket.IO CORS allow-list resolution + rejection logging.
|
|
|
|
Three concerns lifted out of `web_server.py`:
|
|
|
|
- :func:`resolve_cors_origins` — read the user's
|
|
``security.cors_origins`` config setting (string, list, or unset) and
|
|
return what to hand to Flask-SocketIO's ``cors_allowed_origins``
|
|
parameter: an empty list (same-origin only), the literal ``'*'``
|
|
(wildcard, opt-in), or a list of explicit origin URLs.
|
|
|
|
- :func:`will_reject` — predict whether engineio's CORS check will
|
|
reject a request, given the resolved allow-list, the request's
|
|
``Origin`` header, and the request's ``Host`` header. Used to log a
|
|
helpful warning *before* engineio silently 403s a WebSocket upgrade.
|
|
(Without this, the user just sees a half-broken UI with no live
|
|
updates and nothing in the logs explaining why.)
|
|
|
|
- :class:`RejectionLogger` — threadsafe dedup wrapper around the warning
|
|
emitter. Each unique origin is logged once per process so a malicious
|
|
site repeatedly hammering the WS endpoint can't spam logs.
|
|
|
|
Pure logic, no Flask app dependency. Web_server.py imports these and
|
|
wires them into the SocketIO init + a Flask ``before_request`` hook.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from typing import Any, List, Optional, Set, Union
|
|
|
|
|
|
# What ``cors_allowed_origins`` accepts and what we hand to Flask-SocketIO:
|
|
#
|
|
# - ``None`` → engineio's same-origin default. engineio computes the
|
|
# allowed origin list from the request itself: ``scheme://HTTP_HOST``
|
|
# plus ``X-Forwarded-Proto://X-Forwarded-Host`` when those headers are
|
|
# present. Reverse proxies that set X-Forwarded-Host (Nginx with
|
|
# ``proxy_set_header X-Forwarded-Host`` — and Caddy/Traefik by default)
|
|
# work transparently. THE SECURE DEFAULT.
|
|
#
|
|
# - ``'*'`` → allow any origin. Insecure; opt-in only.
|
|
#
|
|
# - ``[origin, ...]`` → explicit allow-list. For setups whose Origin
|
|
# matches neither the backend's Host nor any forwarded header.
|
|
#
|
|
# IMPORTANT: do NOT use ``[]``. In engineio that means "disable CORS
|
|
# handling entirely" (server.py:202: ``if cors_allowed_origins != []:``)
|
|
# which is identical to the ``'*'`` wildcard from a security standpoint.
|
|
ResolvedOrigins = Union[List[str], str, None]
|
|
|
|
|
|
def resolve_cors_origins(config_manager: Any) -> ResolvedOrigins:
|
|
"""Resolve the configured Socket.IO allow-list.
|
|
|
|
Reads ``security.cors_origins`` from ``config_manager`` and normalizes
|
|
whatever shape the user typed (or didn't) into one of three values:
|
|
|
|
- ``None`` (the secure default). Hand to Flask-SocketIO and engineio
|
|
enforces same-origin, with automatic support for X-Forwarded-Host
|
|
so reverse-proxy users don't need to configure anything.
|
|
- ``'*'`` — literal wildcard. Allows any origin. Insecure; opt-in.
|
|
- ``[origin, ...]`` — list of explicit origin URLs. For users behind
|
|
a proxy that doesn't send the forwarded headers OR for custom
|
|
contexts (Electron wrappers, browser extensions).
|
|
|
|
Accepts the config value as either a string (comma OR newline
|
|
separated, since the settings UI is a textarea) or a list. Anything
|
|
else falls back to ``None`` — the secure default.
|
|
"""
|
|
raw = config_manager.get('security.cors_origins', None) if config_manager else None
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, str):
|
|
if not raw.strip():
|
|
return None
|
|
parts = [p.strip() for p in raw.replace('\n', ',').split(',')]
|
|
elif isinstance(raw, (list, tuple)):
|
|
parts = [str(p).strip() for p in raw]
|
|
else:
|
|
return None
|
|
parts = [p for p in parts if p]
|
|
if not parts:
|
|
return None
|
|
if any(p == '*' for p in parts):
|
|
return '*'
|
|
return parts
|
|
|
|
|
|
def will_reject(
|
|
allowed: ResolvedOrigins,
|
|
origin: str,
|
|
host: str,
|
|
forwarded_host: str = '',
|
|
) -> bool:
|
|
"""Predict whether engineio's CORS check will reject this request.
|
|
|
|
Mirrors engineio's allow-list / same-origin logic so callers can log
|
|
a helpful warning *before* the rejection happens. Returns ``True``
|
|
when the request will be rejected.
|
|
|
|
Same-origin check: ``Origin``'s ``host[:port]`` portion matches the
|
|
request's ``Host`` header OR the ``X-Forwarded-Host`` header. Engineio
|
|
checks both when ``cors_allowed_origins`` is ``None``; we mirror that
|
|
so reverse-proxy users with proper proxy headers don't trigger
|
|
spurious "rejected" log lines.
|
|
"""
|
|
if allowed == '*':
|
|
return False
|
|
if isinstance(allowed, list) and origin in allowed:
|
|
return False
|
|
# Origin is "scheme://host[:port][/path]"; pull just host[:port].
|
|
origin_host = origin.split('://', 1)[-1].split('/', 1)[0]
|
|
if host and origin_host == host:
|
|
return False
|
|
if forwarded_host and origin_host == forwarded_host.split(',')[0].strip():
|
|
return False
|
|
return True
|
|
|
|
|
|
class RejectionLogger:
|
|
"""Threadsafe dedup wrapper that logs each rejected origin only once.
|
|
|
|
Engineio silently 403s WebSocket upgrades from disallowed origins.
|
|
Without a log line the user sees a half-broken UI (no live progress,
|
|
no toasts) and has no idea what's wrong. This class watches incoming
|
|
requests via :meth:`maybe_log` and emits a clear warning the first
|
|
time each unique origin appears, telling the user where to add it.
|
|
|
|
Bounded by the number of unique origins ever attempted; cleared on
|
|
process restart. The dedup is intentional — a malicious site
|
|
hammering the endpoint shouldn't be able to spam logs.
|
|
"""
|
|
|
|
def __init__(self, logger: Any):
|
|
self._logger = logger
|
|
self._seen: Set[str] = set()
|
|
self._lock = threading.Lock()
|
|
|
|
def maybe_log(
|
|
self,
|
|
allowed: ResolvedOrigins,
|
|
origin: Optional[str],
|
|
host: str,
|
|
forwarded_host: str = '',
|
|
) -> bool:
|
|
"""Log a rejection warning if applicable, deduped.
|
|
|
|
Returns ``True`` if a warning was emitted this call. Designed to
|
|
be safe to call from a Flask ``before_request`` hook on every
|
|
Socket.IO request — it short-circuits early on requests that
|
|
won't be rejected (no Origin header, allowed origin, same-origin
|
|
match against either Host or X-Forwarded-Host).
|
|
"""
|
|
if not origin:
|
|
return False # Non-browser clients (curl, server-to-server)
|
|
if not will_reject(allowed, origin, host, forwarded_host):
|
|
return False
|
|
with self._lock:
|
|
if origin in self._seen:
|
|
return False
|
|
self._seen.add(origin)
|
|
self._logger.warning(
|
|
f"[Socket.IO] Rejecting WebSocket connection from origin '{origin}' "
|
|
f"(request Host='{host}'). If this is your reverse-proxy or custom "
|
|
f"domain, add it to Settings → Security → Allowed WebSocket Origins."
|
|
)
|
|
return True
|
|
|
|
def reset_for_tests(self) -> None:
|
|
"""Clear the dedup cache. Test-only."""
|
|
with self._lock:
|
|
self._seen.clear()
|
|
|
|
|
|
def log_startup_status(allowed: ResolvedOrigins, logger: Any) -> None:
|
|
"""Emit a one-shot startup log line describing the resolved policy.
|
|
|
|
- For ``'*'`` (wildcard) → warning, since it's a security risk.
|
|
- For a non-empty list → info, so the user can confirm their config
|
|
took effect.
|
|
- For ``None`` (same-origin default) → silent. That's the default;
|
|
nothing noteworthy.
|
|
"""
|
|
if allowed == '*':
|
|
logger.warning(
|
|
"[Socket.IO] cors_allowed_origins is set to '*' — any website can open "
|
|
"a WebSocket to this instance. Set Settings → Security → Allowed Origins "
|
|
"to a specific list (or leave empty for same-origin only) to lock this down."
|
|
)
|
|
elif allowed:
|
|
logger.info(f"[Socket.IO] Allowed cross-origin connections from: {allowed}")
|