security: pre-public-deploy hardening — session, CSRF, headers, healthz, const-eq

Captured by 2026-05-02 CVE audit (memory/cauldron-cve-audit.md). Code-only
change; deploy is gated until current discover scrape pipeline drains.

config.py: add CAULDRON_BASE_URL + CAULDRON_BEHIND_TLS env-driven fields.

server.py:
  - SESSION_COOKIE_SECURE flips on when behind_tls (CVE-D1)
  - PERMANENT_SESSION_LIFETIME=14d + session.permanent=True at login,
    SESSION_REFRESH_EACH_REQUEST sliding expiry (CVE-D2)
  - MAX_CONTENT_LENGTH=1 MiB request-body cap (CVE-G1)
  - ProxyFix wrapped when behind_tls so request.is_secure / .host reflect
    rackham's TLS endpoint, not the OpenVPN-internal :7790 (CVE-J6)
  - @before_request CSRF Origin/Referer guard, exempts GET-class methods
    and Bearer-auth callers, only enforced when CAULDRON_BASE_URL is set
    so LAN dev still works (CVE-A1)
  - @after_request security headers: X-Frame-Options DENY, nosniff,
    Referrer-Policy same-origin, Permissions-Policy interest-cohort=(),
    CSP (self + inline for now), HSTS only when behind_tls (CVE-E1)
  - /healthz trimmed to public {"ok": bool} liveness probe; detailed
    upstream status moved to bearer-gated /api/admin/healthz so error
    strings don't leak internal LAN topology (CVE-E3)
  - _const_eq replaced with hmac.compare_digest — drops the early-return
    length check that itself was a side channel for probing bearer
    length (CVE-A3)

Apache vhost + actual public deploy still gated. Defaults are dev-safe:
no env vars set → no behavior change vs. current LAN HTTP deploy.
This commit is contained in:
Kayos 2026-05-02 16:19:32 -07:00
parent ed0894ddca
commit 5c60b7a115
2 changed files with 125 additions and 8 deletions

View file

@ -42,6 +42,18 @@ class Config:
# hidden. Default empty = nobody gets admin tools (safe-fail). # hidden. Default empty = nobody gets admin tools (safe-fail).
admin_subs: tuple[str, ...] admin_subs: tuple[str, ...]
# External base URL where cauldron is reachable (e.g. https://cauldron.sulkta.com).
# Empty for LAN-only HTTP deploys. When set:
# - the @before_request CSRF guard enforces Origin == this URL
# - HSTS is emitted by the security-headers @after_request
# - SESSION_COOKIE_SECURE flips on (cookie won't ride plain HTTP)
# - werkzeug.middleware.proxy_fix.ProxyFix is wrapped (1 hop trusted)
base_url: str
# Whether the deploy is fronted by TLS (rackham Apache → cauldron over OpenVPN).
# Independent toggle from base_url so dev/staging can override.
behind_tls: bool
def load() -> Config: def load() -> Config:
return Config( return Config(
@ -75,4 +87,6 @@ def load() -> Config:
for s in os.environ.get("CAULDRON_ADMIN_SUBS", "").split(",") for s in os.environ.get("CAULDRON_ADMIN_SUBS", "").split(",")
if s.strip() if s.strip()
), ),
base_url=os.environ.get("CAULDRON_BASE_URL", "").rstrip("/"),
behind_tls=os.environ.get("CAULDRON_BEHIND_TLS", "false").lower() in ("1", "true", "yes"),
) )

View file

@ -21,6 +21,7 @@ Routes (current):
POST /api/sterilize/preview/<slug> (admin bearer) v0.1 sterilizer POST /api/sterilize/preview/<slug> (admin bearer) v0.1 sterilizer
POST /api/sterilize/apply/<slug> (admin bearer) v0.1 sterilizer POST /api/sterilize/apply/<slug> (admin bearer) v0.1 sterilizer
""" """
import hmac
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from functools import wraps from functools import wraps
@ -28,6 +29,7 @@ import requests
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
from flask import Flask, jsonify, redirect, render_template, request, session, url_for from flask import Flask, jsonify, redirect, render_template, request, session, url_for
from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import ConnectionError as RequestsConnectionError
from werkzeug.middleware.proxy_fix import ProxyFix
from .config import load from .config import load
from .crypto import TokenCrypto from .crypto import TokenCrypto
@ -60,10 +62,27 @@ def create_app() -> Flask:
app.config.update( app.config.update(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SAMESITE="Lax",
# NOT setting SESSION_COOKIE_SECURE=True — LAN is plain HTTP for now. # SESSION_COOKIE_SECURE: env-gated. ON when behind TLS so the
# If we ever front this with TLS, flip secure=True. # session cookie won't ride over plain HTTP (sslstrip / mixed-
# content downgrade). Audit CVE-D1 (2026-05-02). Off in dev
# so LAN HTTP development still works.
SESSION_COOKIE_SECURE=cfg.behind_tls,
# 14-day session lifetime + refresh-each-request idle slide.
# Audit CVE-D2 (2026-05-02): Flask's default has no expiry.
PERMANENT_SESSION_LIFETIME=timedelta(days=14),
SESSION_REFRESH_EACH_REQUEST=True,
# 1 MiB body cap. Cauldron POSTs are tiny JSON; no legitimate
# reason for a request body to exceed this. Audit CVE-G1.
MAX_CONTENT_LENGTH=1 * 1024 * 1024,
) )
# Honor X-Forwarded-Proto/Host/For from the rackham reverse proxy
# so request.is_secure / request.host reflect the public-facing TLS
# endpoint, not the OpenVPN-internal :7790. One hop trusted (audit
# CVE-J6, ProxyFix recommendation).
if cfg.behind_tls:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
# Apply migrations on startup # Apply migrations on startup
applied = db.migrate() applied = db.migrate()
if applied: if applied:
@ -154,6 +173,64 @@ def create_app() -> Flask:
u = session.get("user") or {} u = session.get("user") or {}
return {"is_admin": u.get("sub") in cfg.admin_subs} return {"is_admin": u.get("sub") in cfg.admin_subs}
# CSRF Origin/Referer check (audit CVE-A1, 2026-05-02).
# SAMESITE=Lax alone doesn't cover same-site subdomain CSRF (a
# compromised *.sulkta.com page POSTing to cauldron.sulkta.com
# carries cookies). When CAULDRON_BASE_URL is set, every state-
# mutating request must have an Origin (or Referer) that starts
# with that base. Bearer-token API calls are exempt — no cookie
# means no CSRF surface. Pure-GET/HEAD/OPTIONS are exempt.
@app.before_request
def _csrf_origin_guard():
if not cfg.base_url:
return # LAN-only deploy — same-origin is implicit
if request.method in ("GET", "HEAD", "OPTIONS"):
return
# Bearer-auth callers don't carry cookies → no CSRF
if request.headers.get("Authorization", "").startswith("Bearer "):
return
# Origin is the canonical signal; Referer is a fallback
origin = request.headers.get("Origin") or ""
referer = request.headers.get("Referer") or ""
ok = (
(origin and origin.startswith(cfg.base_url))
or (referer and referer.startswith(cfg.base_url))
)
if not ok:
app.logger.warning(
"csrf reject: method=%s path=%s origin=%r referer=%r",
request.method, request.path, origin[:100], referer[:100],
)
if request.path.startswith("/api/"):
return jsonify({"error": "csrf_origin_mismatch"}), 403
return ("Cross-origin request rejected.", 403)
# Security response headers (audit CVE-E1, 2026-05-02). HSTS only
# when behind TLS (sending HSTS over HTTP is invalid). CSP is
# permissive on inline because templates use them; tighten later.
@app.after_request
def _security_headers(resp):
resp.headers.setdefault("X-Frame-Options", "DENY")
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "same-origin")
resp.headers.setdefault("Permissions-Policy", "interest-cohort=()")
resp.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: https:; "
"frame-ancestors 'none'; "
"form-action 'self';",
)
if cfg.behind_tls:
resp.headers.setdefault(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload",
)
return resp
# ---------- helpers -------------------------------------------------- # ---------- helpers --------------------------------------------------
def require_bearer(fn): def require_bearer(fn):
@ -285,6 +362,27 @@ def create_app() -> Flask:
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
"""Liveness probe. Public, intentionally minimal — returns
{"ok": true} or {"ok": false} ONLY. Audit CVE-E3 (2026-05-02):
the previous version echoed upstream error strings (clawdforge URL,
DB error message including hostname/user) which leak internal LAN
topology to anyone who can reach cauldron.sulkta.com/healthz.
Detailed upstream check moved to /api/admin/healthz (bearer-only)."""
try:
with db.conn() as c, c.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return jsonify({"ok": True})
except Exception:
return jsonify({"ok": False}), 503
@app.get("/api/admin/healthz")
@require_bearer
def admin_healthz():
"""Detailed upstream health for operators. Returns the dict the
old public /healthz used to expose: clawdforge status, DB status,
any error strings. Bearer-gated since these strings leak internal
topology."""
upstream = {} upstream = {}
try: try:
upstream["clawdforge"] = forge.healthz() upstream["clawdforge"] = forge.healthz()
@ -360,6 +458,11 @@ def create_app() -> Flask:
db.upsert_user(sub=sub, email=email, display_name=name) db.upsert_user(sub=sub, email=email, display_name=name)
session["user"] = {"sub": sub, "email": email, "name": name} session["user"] = {"sub": sub, "email": email, "name": name}
# Mark session permanent so PERMANENT_SESSION_LIFETIME (14d) is
# honored. Without this, Flask treats the session as a browser-
# session cookie (no Expires) and tab-close kills it. Audit
# CVE-D2 (2026-05-02).
session.permanent = True
return redirect(session.pop("post_login_next", "/me")) return redirect(session.pop("post_login_next", "/me"))
@app.post("/logout") @app.post("/logout")
@ -2532,12 +2635,12 @@ def _index_row_to_card(row: dict, pick_slugs: set[str], mealie_public_url: str =
def _const_eq(a: str, b: str) -> bool: def _const_eq(a: str, b: str) -> bool:
if len(a) != len(b): """Constant-time string compare for bearer-token validation. Audit
return False CVE-A3 (2026-05-02): the prior hand-rolled XOR loop early-returned
diff = 0 on length mismatch, which is itself a side-channel an attacker
for x, y in zip(a.encode(), b.encode()): can probe the admin-bearer length. hmac.compare_digest handles
diff |= x ^ y both length and content in constant time."""
return diff == 0 return hmac.compare_digest(a.encode(), b.encode())
def _json_loads(s): def _json_loads(s):