From 5e5bc12e45b3d753507d162aaffdb9b8db68f928 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 20:48:51 -0700 Subject: [PATCH] Security: add gated security headers in reverse-proxy mode (Tier 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold a conservative security-header set into the SAME opt-in proxy mode, so it's zero-impact when off. When security.trust_reverse_proxy is on, an after_request adds X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, and HSTS (safe — only honoured over the proxy's HTTPS), via setdefault so it never clobbers a header the proxy already set. No CSP (needs per-deploy tuning; better at the proxy). When OFF (default), the after_request isn't registered → no headers added. Tests: off adds none of the headers; on adds all three. Doc updated. 6 tests pass. --- Support/REVERSE-PROXY.md | 18 ++++++++++++++---- core/security/reverse_proxy.py | 16 ++++++++++++++++ tests/test_reverse_proxy_mode.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/Support/REVERSE-PROXY.md b/Support/REVERSE-PROXY.md index 08050924..acf8e488 100644 --- a/Support/REVERSE-PROXY.md +++ b/Support/REVERSE-PROXY.md @@ -25,10 +25,20 @@ terminates TLS, opt in by setting this in your `config.json`: } ``` -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. +When enabled, SoulSync: +- trusts `X-Forwarded-For/Proto/Host/Port` from **one** proxy hop (correct client + IP, HTTPS detection, redirects), +- marks its session cookie `Secure` (HTTPS-only) + `SameSite=Lax`, and +- sends conservative security headers (`X-Content-Type-Options: nosniff`, + `X-Frame-Options: SAMEORIGIN`, `Strict-Transport-Security`). No CSP is set — tune + one at your proxy if you want it. + +**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. With it +off, none of the above applies and SoulSync behaves exactly as before. + +> The launch PIN is also brute-force limited (10 wrong attempts from an IP → a +> short cooldown), regardless of this setting — a correct PIN is never affected. Restart SoulSync after changing it. diff --git a/core/security/reverse_proxy.py b/core/security/reverse_proxy.py index f7733c9d..ab1a1689 100644 --- a/core/security/reverse_proxy.py +++ b/core/security/reverse_proxy.py @@ -35,6 +35,22 @@ def apply_reverse_proxy_mode(app, config_get) -> bool: 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" + + # Security headers — registered ONLY in proxy mode (so a direct/LAN install + # gets none of them). Conservative set that won't break a same-origin app: + # nosniff, clickjacking protection, and HSTS (safe: only honoured over the + # HTTPS the proxy terminates). No CSP here — it needs per-deployment tuning + # and is better added at the proxy. setdefault() so we never clobber a + # header the proxy already set. + @app.after_request + def _security_headers(response): + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") + response.headers.setdefault( + "Strict-Transport-Security", "max-age=31536000; includeSubDomains" + ) + return response + return True except Exception: # If anything goes wrong, behave like off — never break startup over this. diff --git a/tests/test_reverse_proxy_mode.py b/tests/test_reverse_proxy_mode.py index d938ef68..c69abe24 100644 --- a/tests/test_reverse_proxy_mode.py +++ b/tests/test_reverse_proxy_mode.py @@ -50,3 +50,32 @@ def test_failure_falls_back_to_noop(): raise RuntimeError('config exploded') assert apply_reverse_proxy_mode(app, boom) is False assert not isinstance(app.wsgi_app, ProxyFix) + + +def test_off_adds_no_security_headers(): + app = Flask(__name__) + apply_reverse_proxy_mode(app, _cfg(False)) + + @app.route('/ping') + def _ping(): + return 'ok' + + resp = app.test_client().get('/ping') + # direct/LAN install: none of the proxy-mode headers are added + assert 'X-Content-Type-Options' not in resp.headers + assert 'X-Frame-Options' not in resp.headers + assert 'Strict-Transport-Security' not in resp.headers + + +def test_on_adds_security_headers(): + app = Flask(__name__) + apply_reverse_proxy_mode(app, _cfg(True)) + + @app.route('/ping') + def _ping(): + return 'ok' + + resp = app.test_client().get('/ping') + assert resp.headers.get('X-Content-Type-Options') == 'nosniff' + assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN' + assert 'max-age=' in resp.headers.get('Strict-Transport-Security', '')