From 5c60b7a115ce4d164588aa9d4fa9ee9f6e50dae4 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 2 May 2026 16:19:32 -0700 Subject: [PATCH] =?UTF-8?q?security:=20pre-public-deploy=20hardening=20?= =?UTF-8?q?=E2=80=94=20session,=20CSRF,=20headers,=20healthz,=20const-eq?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cauldron/config.py | 14 ++++++ cauldron/server.py | 119 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/cauldron/config.py b/cauldron/config.py index 787e205..a950a6f 100644 --- a/cauldron/config.py +++ b/cauldron/config.py @@ -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"), ) diff --git a/cauldron/server.py b/cauldron/server.py index ad21742..f7d5065 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -21,6 +21,7 @@ Routes (current): POST /api/sterilize/preview/ (admin bearer) v0.1 sterilizer POST /api/sterilize/apply/ (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):