Compare commits

..

2 commits

Author SHA1 Message Date
Brandon Walter
3a9bdc9e15 feat: CSP + security headers middleware + session-fixation defense (1.0.0-27)
Some checks are pending
Security scan / pip-audit (push) Waiting to run
Security scan / bandit (push) Waiting to run
Security scan / gitleaks (push) Waiting to run
#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>
2026-05-02 18:28:13 -04:00
Brandon Walter
11218753ce feat: secret handling — status badges + redacted endpoint + rotation audit (1.0.0-26)
Closes #5 of the post-Codex hardening list:

* Settings UI now shows a `[set]` (green) or `[unset]` (gray) badge next
  to every password/key field. Tells the operator at a glance which
  secrets are configured without ever rendering the value.

* SSH key gets a granular source label: `set (environment variable)`,
  `set (mounted secret)`, or `set (stored in settings DB — prefer a
  mounted secret in production)`. Same hint copy in the field's help
  text now actively recommends `/run/secrets/ssh_key` over the textarea.

* New `GET /api/v1/settings/redacted` admin-only endpoint dumps every
  editable setting with secrets replaced by `***`, plus the per-secret
  status map. Useful for ops triage ("what's actually loaded?") without
  the secrets ever leaving the container or hitting a transcript.

* `POST /api/v1/settings` writes a `settings_secret_changed` audit event
  whenever a non-empty secret is rotated. Records field names, operator,
  source IP — never the value. Lets the audit page answer "who rotated
  the SMTP password last week?".

Internal: `_SECRET_FIELDS` constant in routes.py is now the single
source of truth for which fields get the redaction / audit treatment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:15:57 -04:00
5 changed files with 170 additions and 12 deletions

View file

@ -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-25" 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

View file

@ -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).

View file

@ -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)
@ -1280,6 +1288,7 @@ async def settings_page(
return templates.TemplateResponse(request, "settings.html", { return templates.TemplateResponse(request, "settings.html", {
"request": request, "request": request,
"editable": editable, "editable": editable,
"secret_status": _secret_status(),
"smtp_enabled": bool(settings.smtp_host), "smtp_enabled": bool(settings.smtp_host),
"ssh_configured": _ssh.is_configured(), "ssh_configured": _ssh.is_configured(),
"app_version": settings.app_version, "app_version": settings.app_version,
@ -1288,20 +1297,95 @@ async def settings_page(
}) })
# Field names that hold secrets and must never be rendered to the UI or
# included in the redacted-settings dump verbatim.
_SECRET_FIELDS = ("smtp_password", "ssh_password", "ssh_key", "truenas_api_key")
def _secret_status() -> dict[str, str]:
"""Per-secret display string for the settings page so the operator can
see whether each secret is configured (and how) without ever rendering
the value. Distinguishes env-var, mounted-file, and DB-stored sources
for ssh_key the others can only come from the live settings object."""
import os as _os
from app.ssh_client import _MOUNTED_KEY_PATH
def _has(field: str) -> bool:
v = getattr(settings, field, "")
return bool(v)
# ssh_key gets the most granular treatment because we actively prefer
# the mounted file path in production but the textarea is still wired.
if _os.environ.get("SSH_KEY"):
ssh_key_status = "set (environment variable)"
elif _has("ssh_key"):
ssh_key_status = "set (stored in settings DB — prefer a mounted secret in production)"
elif _os.path.exists(
_os.environ.get("SSH_KEY_FILE", _MOUNTED_KEY_PATH)
):
ssh_key_status = "set (mounted secret)"
else:
ssh_key_status = "unset"
return {
"smtp_password": "set" if _has("smtp_password") else "unset",
"ssh_password": "set" if _has("ssh_password") else "unset",
"ssh_key": ssh_key_status,
"truenas_api_key": "set" if _has("truenas_api_key") else "unset",
}
@router.get("/api/v1/settings/redacted")
async def get_settings_redacted(request: Request):
"""Admin-only diagnostic dump of every editable setting with secrets
replaced by '***'. Useful for ops triage ("what's actually loaded
right now?") without leaking the real values into the transcript."""
user = request.state.current_user
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
out: dict[str, object] = {}
for field in settings_store._EDITABLE.keys():
val = getattr(settings, field, None)
if field in _SECRET_FIELDS:
out[field] = "***" if val else None
else:
out[field] = val
out["_secret_status"] = _secret_status()
return out
@router.post("/api/v1/settings") @router.post("/api/v1/settings")
async def save_settings(body: dict): async def save_settings(request: Request, body: dict):
"""Save editable runtime settings. Secrets are only updated if non-empty.""" """Save editable runtime settings. Secrets are only updated if non-empty."""
# Don't overwrite secrets if client sent empty string user = request.state.current_user
for secret_field in ("smtp_password", "truenas_api_key", "ssh_password", "ssh_key"): # Don't overwrite secrets if client sent empty string. Track which
if secret_field in body and body[secret_field] == "": # ones DID get a real change so we can audit the rotation.
rotated: list[str] = []
for secret_field in _SECRET_FIELDS:
if secret_field in body:
if body[secret_field] == "":
del body[secret_field] del body[secret_field]
else:
rotated.append(secret_field)
try: try:
saved = settings_store.save(body) saved = settings_store.save(body)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) raise HTTPException(status_code=422, detail=str(exc))
return {"saved": True, "keys": saved} # Audit secret rotations — never log the value, only the field name +
# operator + source IP. Lets `audit` page answer "who rotated the
# SMTP password last week?"
if rotated and user:
await auth.audit_auth_event(
"settings_secret_changed",
user.username,
f"Rotated secrets from {_client_ip(request)}: {', '.join(sorted(rotated))}",
)
return {"saved": True, "keys": saved, "rotated_secrets": rotated}
@router.post("/api/v1/settings/test-smtp") @router.post("/api/v1/settings/test-smtp")

View file

@ -2426,6 +2426,31 @@ tr.drawer-row-active {
color: var(--yellow); color: var(--yellow);
} }
/* -----------------------------------------------------------------------
Settings: secret-status pills next to password/key labels
----------------------------------------------------------------------- */
.secret-status {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.secret-status.secret-set {
background: color-mix(in srgb, var(--green, #39c179) 14%, transparent);
color: var(--green, #39c179);
border: 1px solid color-mix(in srgb, var(--green, #39c179) 35%, transparent);
}
.secret-status.secret-unset {
background: color-mix(in srgb, var(--text-muted) 14%, transparent);
color: var(--text-muted);
border: 1px solid color-mix(in srgb, var(--text-muted) 35%, transparent);
}
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Login screen Login screen
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */

View file

@ -61,7 +61,7 @@
<input class="sf-input" id="smtp_user" name="smtp_user" type="text" <input class="sf-input" id="smtp_user" name="smtp_user" type="text"
value="{{ editable.smtp_user }}" autocomplete="off"> value="{{ editable.smtp_user }}" autocomplete="off">
<label for="smtp_password">Password</label> <label for="smtp_password">Password <span class="secret-status secret-{{ 'set' if secret_status.smtp_password == 'set' else 'unset' }}">[{{ secret_status.smtp_password }}]</span></label>
<input class="sf-input" id="smtp_password" name="smtp_password" type="password" <input class="sf-input" id="smtp_password" name="smtp_password" type="password"
placeholder="leave blank to keep existing" autocomplete="new-password"> placeholder="leave blank to keep existing" autocomplete="new-password">
@ -125,17 +125,19 @@
<input class="sf-input" id="ssh_user" name="ssh_user" type="text" <input class="sf-input" id="ssh_user" name="ssh_user" type="text"
value="{{ editable.ssh_user }}" placeholder="root"> value="{{ editable.ssh_user }}" placeholder="root">
<label for="ssh_password">Password</label> <label for="ssh_password">Password <span class="secret-status secret-{{ 'set' if secret_status.ssh_password == 'set' else 'unset' }}">[{{ secret_status.ssh_password }}]</span></label>
<input class="sf-input" id="ssh_password" name="ssh_password" type="password" <input class="sf-input" id="ssh_password" name="ssh_password" type="password"
placeholder="leave blank to keep existing" autocomplete="new-password"> placeholder="leave blank to keep existing" autocomplete="new-password">
<label for="ssh_key">Private Key</label> <label for="ssh_key">Private Key <span class="secret-status secret-{{ 'set' if 'set' in secret_status.ssh_key else 'unset' }}">[{{ secret_status.ssh_key }}]</span></label>
<div> <div>
<textarea class="sf-input sf-textarea" id="ssh_key" name="ssh_key" <textarea class="sf-input sf-textarea" id="ssh_key" name="ssh_key"
rows="6" placeholder="Paste PEM private key here (-----BEGIN ... KEY-----). Leave blank to keep existing." autocomplete="off"></textarea> rows="6" placeholder="Paste PEM private key here (-----BEGIN ... KEY-----). Leave blank to keep existing." autocomplete="off"></textarea>
<span class="sf-hint" style="margin-top:3px"> <span class="sf-hint" style="margin-top:3px">
Either password or key auth. Key takes precedence if both are set. Either password or key auth. Key takes precedence if both are set.
Key is stored securely in <code>/data/settings_overrides.json</code>. <strong>For production, mount the key as a Docker secret at
<code>/run/secrets/ssh_key</code> instead of pasting it here</strong>
— that path is checked automatically when no key is in settings.
</span> </span>
</div> </div>
@ -290,7 +292,7 @@
<input class="sf-input" id="truenas_base_url" name="truenas_base_url" type="text" <input class="sf-input" id="truenas_base_url" name="truenas_base_url" type="text"
value="{{ editable.truenas_base_url }}" placeholder="http://10.0.0.x"> value="{{ editable.truenas_base_url }}" placeholder="http://10.0.0.x">
<label for="truenas_api_key">API Key</label> <label for="truenas_api_key">API Key <span class="secret-status secret-{{ 'set' if secret_status.truenas_api_key == 'set' else 'unset' }}">[{{ secret_status.truenas_api_key }}]</span></label>
<input class="sf-input" id="truenas_api_key" name="truenas_api_key" type="password" <input class="sf-input" id="truenas_api_key" name="truenas_api_key" type="password"
placeholder="leave blank to keep existing" autocomplete="new-password"> placeholder="leave blank to keep existing" autocomplete="new-password">