soulsync/tests/test_reverse_proxy_mode.py
BoulderBadgeDad aa3aae695d 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.
2026-06-10 20:36:49 -07:00

52 lines
1.9 KiB
Python

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