Compare commits
No commits in common. "3a9bdc9e15c1cb8b359e414ca00f5f341f206031" and "992e2c47b3f5bbd9ab2da957eea8dbda7b9f0515" have entirely different histories.
3a9bdc9e15
...
992e2c47b3
5 changed files with 12 additions and 170 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-27"
|
app_version: str = "1.0.0-25"
|
||||||
|
|
||||||
# ---- 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,52 +121,6 @@ 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
|
||||||
|
|
@ -204,7 +158,6 @@ 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,11 +298,6 @@ 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)
|
||||||
|
|
@ -326,9 +321,6 @@ 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)
|
||||||
|
|
@ -1288,7 +1280,6 @@ 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,
|
||||||
|
|
@ -1297,95 +1288,20 @@ 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(request: Request, body: dict):
|
async def save_settings(body: dict):
|
||||||
"""Save editable runtime settings. Secrets are only updated if non-empty."""
|
"""Save editable runtime settings. Secrets are only updated if non-empty."""
|
||||||
user = request.state.current_user
|
# Don't overwrite secrets if client sent empty string
|
||||||
# Don't overwrite secrets if client sent empty string. Track which
|
for secret_field in ("smtp_password", "truenas_api_key", "ssh_password", "ssh_key"):
|
||||||
# ones DID get a real change so we can audit the rotation.
|
if secret_field in body and body[secret_field] == "":
|
||||||
rotated: list[str] = []
|
del body[secret_field]
|
||||||
for secret_field in _SECRET_FIELDS:
|
|
||||||
if secret_field in body:
|
|
||||||
if 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))
|
||||||
|
|
||||||
# Audit secret rotations — never log the value, only the field name +
|
return {"saved": True, "keys": saved}
|
||||||
# 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")
|
||||||
|
|
|
||||||
|
|
@ -2426,31 +2426,6 @@ 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
|
||||||
----------------------------------------------------------------------- */
|
----------------------------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -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 <span class="secret-status secret-{{ 'set' if secret_status.smtp_password == 'set' else 'unset' }}">[{{ secret_status.smtp_password }}]</span></label>
|
<label for="smtp_password">Password</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,19 +125,17 @@
|
||||||
<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 <span class="secret-status secret-{{ 'set' if secret_status.ssh_password == 'set' else 'unset' }}">[{{ secret_status.ssh_password }}]</span></label>
|
<label for="ssh_password">Password</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 <span class="secret-status secret-{{ 'set' if 'set' in secret_status.ssh_key else 'unset' }}">[{{ secret_status.ssh_key }}]</span></label>
|
<label for="ssh_key">Private Key</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.
|
||||||
<strong>For production, mount the key as a Docker secret at
|
Key is stored securely in <code>/data/settings_overrides.json</code>.
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -292,7 +290,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 <span class="secret-status secret-{{ 'set' if secret_status.truenas_api_key == 'set' else 'unset' }}">[{{ secret_status.truenas_api_key }}]</span></label>
|
<label for="truenas_api_key">API Key</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">
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue