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).
|
# 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"),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue