Security: opt-in reverse-proxy mode (ProxyFix + Secure cookie) + nginx guide
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.
This commit is contained in:
parent
d82d02b921
commit
aa3aae695d
5 changed files with 235 additions and 0 deletions
122
Support/REVERSE-PROXY.md
Normal file
122
Support/REVERSE-PROXY.md
Normal file
|
|
@ -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`.
|
||||
44
core/security/reverse_proxy.py
Normal file
44
core/security/reverse_proxy.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
52
tests/test_reverse_proxy_mode.py
Normal file
52
tests/test_reverse_proxy_mode.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue