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:
parent
0d1e949798
commit
5e5bc12e45
3 changed files with 59 additions and 4 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
Loading…
Reference in a new issue