Files
ServerKit/backend/app/middleware/security.py
T
Juan DenisandClaude Opus 4.8 a09bf0fbbe feat(security): gate panel HSTS on installer SSL mode (keep HTTPS optional)
The Flask middleware emitted HSTS with includeSubDomains+preload whenever not in
debug mode -- even on HTTP-only or Cloudflare-Flexible installs, where preload is
a hard-to-reverse commitment. HTTPS is intentionally optional, so HSTS is now
gated on the operator's recorded SSL choice.

- config._resolve_ssl_mode(): SERVERKIT_SSL_MODE env -> /etc/serverkit/ssl-mode
  file -> safe 'insecure' default. Exposed as SSL_MODE / HSTS_ENABLED.
- security middleware sends HSTS only when HSTS_ENABLED and not debug. Behind a
  proxy Flask can't distinguish real TLS from a Flexible edge via
  X-Forwarded-Proto, so we trust the installer's choice, not the request scheme.
- install.sh records SERVERKIT_SSL_MODE in .env; existing installs are covered by
  the /etc/serverkit/ssl-mode file. The nginx edge still emits HSTS in its own
  secure server block, so secure installs are unaffected.

Proving tests in tests/test_security_headers.py. Full backend suite: 342 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:52:40 -04:00

66 lines
2.7 KiB
Python

"""Security middleware for adding security headers to responses."""
from flask import Flask
def register_security_headers(app: Flask):
"""Register security headers as an after_request handler."""
@app.after_request
def add_security_headers(response):
# Prevent clickjacking
response.headers['X-Frame-Options'] = 'DENY'
# Prevent MIME type sniffing
response.headers['X-Content-Type-Options'] = 'nosniff'
# XSS Protection (legacy but still useful for older browsers)
response.headers['X-XSS-Protection'] = '1; mode=block'
# Referrer Policy
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Content Security Policy
# In debug mode, allow inline styles/scripts for Vite dev tooling.
# The production policy intentionally matches the nginx edge config so
# responses served directly by Flask are consistent with Cloudflare/nginx.
if app.debug:
csp_directives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' ws: wss: http://localhost:* http://127.0.0.1:*",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
else:
csp_directives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' ws: wss:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
response.headers['Content-Security-Policy'] = '; '.join(csp_directives)
# Permissions Policy (formerly Feature-Policy)
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
# HSTS - only when the deployment actually terminates HTTPS. The installer
# records that choice (SSL_MODE / HSTS_ENABLED in config); we never
# advertise HSTS or `preload` on a plain-HTTP or Cloudflare-Flexible
# install, since that's a hard-to-reverse commitment and HTTPS is
# intentionally optional. 63072000s = 2 years, matching the nginx edge
# config and the preload-list requirements.
if app.config.get('HSTS_ENABLED') and not app.debug:
response.headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains; preload'
return response