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
|
When enabled, SoulSync:
|
||||||
hop and marks its session cookie `Secure` (HTTPS-only) + `SameSite=Lax`. **Leave
|
- trusts `X-Forwarded-For/Proto/Host/Port` from **one** proxy hop (correct client
|
||||||
it off if you access SoulSync directly over http:// on your LAN** — turning it on
|
IP, HTTPS detection, redirects),
|
||||||
would make the session cookie HTTPS-only and break plain-HTTP access.
|
- 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.
|
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.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_SECURE"] = True
|
||||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
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
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
# If anything goes wrong, behave like off — never break startup over this.
|
# 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')
|
raise RuntimeError('config exploded')
|
||||||
assert apply_reverse_proxy_mode(app, boom) is False
|
assert apply_reverse_proxy_mode(app, boom) is False
|
||||||
assert not isinstance(app.wsgi_app, ProxyFix)
|
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