From aa3aae695ddf50e02a357c06064add5fbff7f12b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 20:36:49 -0700 Subject: [PATCH] Security: opt-in reverse-proxy mode (ProxyFix + Secure cookie) + nginx guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 of "secure behind a reverse proxy". STRICTLY opt-in so direct/LAN installs are byte-for-byte unchanged. - core/security/reverse_proxy.py: apply_reverse_proxy_mode(app, config_get) — a no-op unless security.trust_reverse_proxy=true. When OFF (default), the app is untouched: no ProxyFix, X-Forwarded-* stays UNtrusted (a direct client can't spoof its IP/scheme), session cookie keeps Flask defaults. When ON (operator is behind nginx/Caddy/Traefik with TLS): trust one proxy hop's X-Forwarded-*, and mark the session cookie Secure + SameSite=Lax. Any config error → safe no-op, never breaks startup. - Wired once at app init. - Support/REVERSE-PROXY.md: nginx (with the Socket.IO Upgrade headers people always miss) / Caddy / Traefik configs, the setting, and the "put auth in front (Authelia/Authentik/oauth2-proxy)" recommendation + the off-for-plain-HTTP note. Tests: off (and missing-key, and a config exception) is a strict no-op — not ProxyFix-wrapped, cookie defaults intact; on wraps ProxyFix + secures the cookie; and the real web_server app is NOT in proxy mode by default. 5 tests pass. --- Support/REVERSE-PROXY.md | 122 ++++++++++++++++++++++++++++ core/security/reverse_proxy.py | 44 ++++++++++ tests/test_credentials_endpoints.py | 9 ++ tests/test_reverse_proxy_mode.py | 52 ++++++++++++ web_server.py | 8 ++ 5 files changed, 235 insertions(+) create mode 100644 Support/REVERSE-PROXY.md create mode 100644 core/security/reverse_proxy.py create mode 100644 tests/test_reverse_proxy_mode.py diff --git a/Support/REVERSE-PROXY.md b/Support/REVERSE-PROXY.md new file mode 100644 index 00000000..08050924 --- /dev/null +++ b/Support/REVERSE-PROXY.md @@ -0,0 +1,122 @@ +# Running SoulSync behind a reverse proxy (nginx / Caddy / Traefik) + +Putting SoulSync behind a reverse proxy lets you serve it over **HTTPS** and — the +important part — put **authentication** in front of it before exposing it to the +internet. This guide covers the safe setup. + +> **The golden rule:** the safest way to expose *any* self-hosted app publicly is +> to require authentication at the proxy (an auth layer), **not** to rely on the +> app's own protection. SoulSync's launch PIN is a useful fallback, but it is not +> a substitute for a real auth layer on a public instance. + +--- + +## 1. Turn on reverse-proxy mode + +By default SoulSync does **not** trust proxy headers (so a direct client can't spoof +its IP or pretend the connection is HTTPS). If you're behind a proxy that +terminates TLS, opt in by setting this in your `config.json`: + +```json +{ + "security": { + "trust_reverse_proxy": true + } +} +``` + +When enabled, SoulSync trusts `X-Forwarded-For/Proto/Host/Port` from **one** proxy +hop and marks its session cookie `Secure` (HTTPS-only) + `SameSite=Lax`. **Leave +it off if you access SoulSync directly over http:// on your LAN** — turning it on +would make the session cookie HTTPS-only and break plain-HTTP access. + +Restart SoulSync after changing it. + +--- + +## 2. nginx + +SoulSync uses WebSockets (Socket.IO), so the `Upgrade`/`Connection` headers are +**required** — without them live updates silently stop working. + +```nginx +server { + listen 443 ssl; + server_name soulsync.example.com; + + ssl_certificate /etc/letsencrypt/live/soulsync.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/soulsync.example.com/privkey.pem; + + # Large library scans / uploads + client_max_body_size 0; + + location / { + proxy_pass http://127.0.0.1:8008; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # Required for Socket.IO / live updates + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 3600s; # long-running scans + proxy_send_timeout 3600s; + } +} +``` + +--- + +## 3. Caddy + +Caddy handles TLS automatically and proxies WebSockets out of the box: + +```caddy +soulsync.example.com { + reverse_proxy 127.0.0.1:8008 +} +``` + +Caddy sets `X-Forwarded-*` for you. (Add an auth provider directive if you want +auth at the proxy — see below.) + +--- + +## 4. Traefik + +Traefik proxies WebSockets automatically and forwards the headers. Point a router +at the SoulSync service on port `8008` with your TLS resolver; no extra WebSocket +config is needed. + +--- + +## 5. Add authentication in front (recommended for public instances) + +Pick one: + +- **Auth proxy** — [Authelia](https://www.authelia.com/), + [Authentik](https://goauthentik.io/), or + [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/). These sit in front + of SoulSync and force a login (with 2FA) before any request reaches it. Best + option for internet exposure. +- **HTTP Basic Auth** — quick and simple (nginx `auth_basic` / Caddy `basicauth`). + Better than nothing; weaker than an auth proxy. +- **SoulSync launch PIN** — set an admin PIN in Settings. Enforced server-side, so + it can't be bypassed by hitting the API directly — but it's a shared PIN, so + treat it as a fallback, not your only gate. + +--- + +## Troubleshooting + +- **Live updates / progress bars don't move** → the WebSocket `Upgrade`/`Connection` + headers are missing (nginx) or your proxy is buffering. Check section 2. +- **Login won't stick / "session expired"** → you enabled `trust_reverse_proxy` but + are reaching SoulSync over plain `http://`. The session cookie is now HTTPS-only; + use `https://`, or turn the setting off for direct HTTP access. +- **Scans time out** → raise `proxy_read_timeout` / `proxy_send_timeout`. diff --git a/core/security/reverse_proxy.py b/core/security/reverse_proxy.py new file mode 100644 index 00000000..f7733c9d --- /dev/null +++ b/core/security/reverse_proxy.py @@ -0,0 +1,44 @@ +"""Opt-in reverse-proxy mode. + +Default OFF. When off this is a strict no-op: the Flask app is left exactly as it +was, ``X-Forwarded-*`` headers are NOT trusted (so a direct client can't spoof its +IP/scheme), and the session cookie keeps Flask's defaults. So a normal direct / +LAN install is byte-for-byte unchanged. + +Only when the operator explicitly sets ``security.trust_reverse_proxy: true`` — +they're running behind nginx / Caddy / Traefik that terminates TLS — do we: + - trust the proxy's ``X-Forwarded-For/Proto/Host/Port`` (correct client IP, + HTTPS detection, redirects), and + - mark the session cookie ``Secure`` (HTTPS-only) + ``SameSite=Lax``. + +Gated this way the security/UX change is scoped strictly to people who turned it +on; everyone else is untouched. +""" + +from __future__ import annotations + +CONFIG_KEY = "security.trust_reverse_proxy" + + +def apply_reverse_proxy_mode(app, config_get) -> bool: + """Apply reverse-proxy hardening to ``app`` iff the operator enabled it. + + ``config_get`` is a ``config_manager.get``-style callable ``(key, default)``. + Returns True if proxy mode was enabled, False (no-op) otherwise. Never raises + out — a failure to enable falls back to the safe no-op behaviour. + """ + try: + if not config_get(CONFIG_KEY, False): + return False + from werkzeug.middleware.proxy_fix import ProxyFix + # Trust exactly one proxy hop for each forwarded header. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) + app.config["SESSION_COOKIE_SECURE"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + return True + except Exception: + # If anything goes wrong, behave like off — never break startup over this. + return False + + +__all__ = ["apply_reverse_proxy_mode", "CONFIG_KEY"] diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index a4dba6b9..6f979779 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -346,3 +346,12 @@ def test_tidal_source_adapter_resolves_per_profile(): from core.playlists.sources.tidal import TidalPlaylistSource src = TidalPlaylistSource(web_server.get_tidal_client_for_profile) assert src._client() is web_server.tidal_client # admin -> global, unchanged + + +def test_real_app_not_in_reverse_proxy_mode_by_default(): + # Direct/LAN installs (no security.trust_reverse_proxy set) must not get + # ProxyFix or a forced-Secure cookie — proves zero impact for normal users. + from werkzeug.middleware.proxy_fix import ProxyFix + assert not isinstance(web_server.app.wsgi_app, ProxyFix) + assert web_server.app.config.get('SESSION_COOKIE_SECURE') in (None, False) + assert web_server.app.config.get('SESSION_COOKIE_SAMESITE') is None diff --git a/tests/test_reverse_proxy_mode.py b/tests/test_reverse_proxy_mode.py new file mode 100644 index 00000000..d938ef68 --- /dev/null +++ b/tests/test_reverse_proxy_mode.py @@ -0,0 +1,52 @@ +"""Opt-in reverse-proxy mode must be a STRICT no-op when off (default), so a +direct/LAN install is byte-for-byte unchanged, and only harden when enabled.""" + +from __future__ import annotations + +from flask import Flask +from werkzeug.middleware.proxy_fix import ProxyFix + +from core.security.reverse_proxy import apply_reverse_proxy_mode, CONFIG_KEY + + +def _cfg(value): + """A config_manager.get-style callable returning `value` for the proxy key.""" + return lambda key, default=None: value if key == CONFIG_KEY else default + + +def test_off_by_default_is_a_strict_noop(): + app = Flask(__name__) + + enabled = apply_reverse_proxy_mode(app, _cfg(False)) # default/off + + assert enabled is False + assert not isinstance(app.wsgi_app, ProxyFix) # NOT wrapped + # Flask defaults untouched — cookie not forced Secure, no SameSite override + assert app.config.get('SESSION_COOKIE_SECURE') in (None, False) + assert app.config.get('SESSION_COOKIE_SAMESITE') is None + + +def test_missing_key_is_also_a_noop(): + app = Flask(__name__) + assert apply_reverse_proxy_mode(app, lambda key, default=None: default) is False + assert not isinstance(app.wsgi_app, ProxyFix) + + +def test_on_wraps_proxyfix_and_secures_cookie(): + app = Flask(__name__) + + enabled = apply_reverse_proxy_mode(app, _cfg(True)) + + assert enabled is True + assert isinstance(app.wsgi_app, ProxyFix) # forwarded headers trusted + assert app.config['SESSION_COOKIE_SECURE'] is True # cookie HTTPS-only + assert app.config['SESSION_COOKIE_SAMESITE'] == 'Lax' + + +def test_failure_falls_back_to_noop(): + # A config_get that raises must not break startup — treated as off. + app = Flask(__name__) + def boom(key, default=None): + raise RuntimeError('config exploded') + assert apply_reverse_proxy_mode(app, boom) is False + assert not isinstance(app.wsgi_app, ProxyFix) diff --git a/web_server.py b/web_server.py index 4e2e1ac0..a69858b4 100644 --- a/web_server.py +++ b/web_server.py @@ -346,6 +346,14 @@ def _init_flask_secret_key(): app.secret_key = _init_flask_secret_key() +# --- Reverse-proxy mode (opt-in, default OFF) --- +# OFF by default → a strict no-op, so direct/LAN installs are unchanged. Only when +# the operator sets security.trust_reverse_proxy=true (behind nginx/Caddy/Traefik +# with TLS) does this trust X-Forwarded-* + mark the session cookie Secure. +from core.security.reverse_proxy import apply_reverse_proxy_mode as _apply_reverse_proxy_mode +if _apply_reverse_proxy_mode(app, config_manager.get): + logger.info("[Security] Reverse-proxy mode ON: trusting X-Forwarded-* and Secure session cookie") + # --- WebSocket (Socket.IO) Setup --- from core.socketio_cors import ( resolve_cors_origins as _resolve_socketio_cors_origins,