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:
parent
ed0894ddca
commit
5c60b7a115
2 changed files with 125 additions and 8 deletions
|
|
@ -42,6 +42,18 @@ class Config:
|
|||
# hidden. Default empty = nobody gets admin tools (safe-fail).
|
||||
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:
|
||||
return Config(
|
||||
|
|
@ -75,4 +87,6 @@ def load() -> Config:
|
|||
for s in os.environ.get("CAULDRON_ADMIN_SUBS", "").split(",")
|
||||
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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ Routes (current):
|
|||
POST /api/sterilize/preview/<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 functools import wraps
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ import requests
|
|||
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
|
||||
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from .config import load
|
||||
from .crypto import TokenCrypto
|
||||
|
|
@ -60,10 +62,27 @@ def create_app() -> Flask:
|
|||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
# NOT setting SESSION_COOKIE_SECURE=True — LAN is plain HTTP for now.
|
||||
# If we ever front this with TLS, flip secure=True.
|
||||
# SESSION_COOKIE_SECURE: env-gated. ON when behind TLS so the
|
||||
# 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
|
||||
applied = db.migrate()
|
||||
if applied:
|
||||
|
|
@ -154,6 +173,64 @@ def create_app() -> Flask:
|
|||
u = session.get("user") or {}
|
||||
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 --------------------------------------------------
|
||||
|
||||
def require_bearer(fn):
|
||||
|
|
@ -285,6 +362,27 @@ def create_app() -> Flask:
|
|||
|
||||
@app.get("/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 = {}
|
||||
try:
|
||||
upstream["clawdforge"] = forge.healthz()
|
||||
|
|
@ -360,6 +458,11 @@ def create_app() -> Flask:
|
|||
|
||||
db.upsert_user(sub=sub, email=email, display_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"))
|
||||
|
||||
@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:
|
||||
if len(a) != len(b):
|
||||
return False
|
||||
diff = 0
|
||||
for x, y in zip(a.encode(), b.encode()):
|
||||
diff |= x ^ y
|
||||
return diff == 0
|
||||
"""Constant-time string compare for bearer-token validation. Audit
|
||||
CVE-A3 (2026-05-02): the prior hand-rolled XOR loop early-returned
|
||||
on length mismatch, which is itself a side-channel — an attacker
|
||||
can probe the admin-bearer length. hmac.compare_digest handles
|
||||
both length and content in constant time."""
|
||||
return hmac.compare_digest(a.encode(), b.encode())
|
||||
|
||||
|
||||
def _json_loads(s):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue