feat: CSP + security headers middleware + session-fixation defense (1.0.0-27)
#6 — defense-in-depth security headers: * New _SecurityHeadersMiddleware emits five headers on every response: - Content-Security-Policy: tight default-src 'self', allow-list the three CDNs we actively load (unpkg for HTMX, cdnjs for QR codes, jsdelivr for xterm.js), plus 'unsafe-inline' for the inline script in settings.html and inline style in job_print.html. Tighten via nonces later if you want true CSP-level XSS protection. - X-Content-Type-Options: nosniff - Referrer-Policy: same-origin - X-Frame-Options: DENY (no clickjacking) - Permissions-Policy: camera/microphone/geolocation/interest-cohort all blocked * Middleware ordering: SecurityHeaders -> AuthGate -> Session, so headers go on EVERY response including 401/403/redirects. #7 — session-fixation defense: * request.session.clear() now runs BEFORE setting user_id/username on successful /login AND /api/v1/auth/setup. Discards any pre-login payload an attacker might have seeded the cookie with. Combined with SameSite=strict + the HMAC-signed Starlette session cookie, this closes the residual fixation surface. Verified: curl -sSI /login returns all five headers; container boots clean; /health 200; existing session for the operator continues to work because we only clear on the LOGIN flow itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
11218753ce
commit
3a9bdc9e15
3 changed files with 56 additions and 1 deletions
|
|
@ -83,7 +83,7 @@ class Settings(BaseSettings):
|
||||||
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
||||||
|
|
||||||
# Application version — used by the /api/v1/updates/check endpoint
|
# Application version — used by the /api/v1/updates/check endpoint
|
||||||
app_version: str = "1.0.0-26"
|
app_version: str = "1.0.0-27"
|
||||||
|
|
||||||
# ---- Authentication (1.0.0-22) ----
|
# ---- Authentication (1.0.0-22) ----
|
||||||
# session_secret: HMAC key for signing session cookies. Empty = generate
|
# session_secret: HMAC key for signing session cookies. Empty = generate
|
||||||
|
|
|
||||||
47
app/main.py
47
app/main.py
|
|
@ -121,6 +121,52 @@ async def lifespan(app: FastAPI):
|
||||||
app = FastAPI(title="TrueNAS Burn-In Dashboard", lifespan=lifespan)
|
app = FastAPI(title="TrueNAS Burn-In Dashboard", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defense-in-depth security headers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# CSP allows the CDNs we actively load:
|
||||||
|
# unpkg.com — htmx + htmx-sse-extension
|
||||||
|
# cdnjs.cloudflare.com — qrcodejs (history print page)
|
||||||
|
# cdn.jsdelivr.net — xterm.js (terminal tab, lazy-loaded)
|
||||||
|
# 'unsafe-inline' is needed for inline <script> in settings.html and
|
||||||
|
# inline <style> in job_print.html. Tighten via nonces later if you
|
||||||
|
# care about CSP-level XSS hardening; for now relies on Jinja2's
|
||||||
|
# autoescape + html.escape on all user-controlled fields.
|
||||||
|
_CSP = " ".join([
|
||||||
|
"default-src 'self';",
|
||||||
|
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net;",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;",
|
||||||
|
"img-src 'self' data:;",
|
||||||
|
"font-src 'self' data:;",
|
||||||
|
"connect-src 'self' ws: wss:;",
|
||||||
|
"object-src 'none';",
|
||||||
|
"base-uri 'self';",
|
||||||
|
"form-action 'self';",
|
||||||
|
"frame-ancestors 'none';",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class _SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Sets security headers that are cheap, effective, and never break
|
||||||
|
the page if you stick to same-origin. CSP is the meaningful one;
|
||||||
|
the others close small XSS / clickjacking / referrer-leak surfaces."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers.setdefault("Content-Security-Policy", _CSP)
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
# Permissions-Policy disables every feature we don't use. The
|
||||||
|
# empty allowlist syntax `()` = block for all origins.
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Permissions-Policy",
|
||||||
|
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth gate — must be added BEFORE include_router so it runs first.
|
# Auth gate — must be added BEFORE include_router so it runs first.
|
||||||
# Path-prefix allowlist below covers anything we want reachable without
|
# Path-prefix allowlist below covers anything we want reachable without
|
||||||
|
|
@ -158,6 +204,7 @@ class _AuthGateMiddleware(BaseHTTPMiddleware):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(_SecurityHeadersMiddleware)
|
||||||
app.add_middleware(_AuthGateMiddleware)
|
app.add_middleware(_AuthGateMiddleware)
|
||||||
# SessionMiddleware must be added LAST (it wraps innermost so request.session
|
# SessionMiddleware must be added LAST (it wraps innermost so request.session
|
||||||
# is populated before AuthGate runs).
|
# is populated before AuthGate runs).
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,11 @@ async def login_submit(request: Request):
|
||||||
|
|
||||||
user = found[0]
|
user = found[0]
|
||||||
auth.clear_login_failures(username, ip)
|
auth.clear_login_failures(username, ip)
|
||||||
|
# Clear any pre-login session keys before populating the new identity.
|
||||||
|
# Closes session-fixation: if an attacker had somehow seeded the
|
||||||
|
# browser with a session cookie, this discards everything in it
|
||||||
|
# before issuing the new authenticated payload.
|
||||||
|
request.session.clear()
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
request.session["username"] = user.username
|
request.session["username"] = user.username
|
||||||
await auth.touch_last_login(user.id)
|
await auth.touch_last_login(user.id)
|
||||||
|
|
@ -321,6 +326,9 @@ async def auth_first_user_setup(request: Request):
|
||||||
user = await auth.create_user(username, password, full_name, is_admin=True)
|
user = await auth.create_user(username, password, full_name, is_admin=True)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
# Same fixation defense as the login flow — discard any pre-existing
|
||||||
|
# session payload before issuing the authenticated identity.
|
||||||
|
request.session.clear()
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
request.session["username"] = user.username
|
request.session["username"] = user.username
|
||||||
await auth.touch_last_login(user.id)
|
await auth.touch_last_login(user.id)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue