Security: add gated security headers in reverse-proxy mode (Tier 2)

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.
This commit is contained in:
BoulderBadgeDad 2026-06-10 20:48:51 -07:00
parent 0d1e949798
commit 5e5bc12e45
3 changed files with 59 additions and 4 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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', '')