soulsync/core/security/reverse_proxy.py
BoulderBadgeDad 5e5bc12e45 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.
2026-06-10 20:48:51 -07:00

60 lines
2.6 KiB
Python

"""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"
# 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.
return False
__all__ = ["apply_reverse_proxy_mode", "CONFIG_KEY"]