diff --git a/.env.example b/.env.example index 8a09945..7cf5ac1 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,27 @@ DB_PASSWORD= # Fernet master key for at-rest encryption of per-user Mealie tokens. # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" CAULDRON_FERNET_KEY= + +# --- Public-deploy hardening (added 2026-05-02 CVE audit) --- +# Comma-separated list of authentik subjects who get the operator-tier +# /me admin tools panel (consolidate, discover scrape). Empty = nobody. +# Cobb's authentik sub goes here for production. +CAULDRON_ADMIN_SUBS= + +# External base URL where cauldron is reachable. Set to your public host +# (e.g. https://cauldron.sulkta.com) when going public; leave empty for +# LAN-only HTTP. When set: enables CSRF Origin guard, HSTS, secure cookie. +CAULDRON_BASE_URL= + +# Whether the deploy is fronted by TLS (rackham apache → cauldron over +# OpenVPN). Independent toggle from base_url so dev/staging can override. +# When true: SESSION_COOKIE_SECURE=True, HSTS header emitted. +CAULDRON_BEHIND_TLS=false + +# Comma-separated CIDR list of trusted proxies whose X-Forwarded-* we +# honor. Empty = trust nothing → ProxyFix is OFF and X-Forwarded-* are +# stripped from every request. For the rackham→OpenVPN→lucy:7790 deploy, +# set this to rackham's WireGuard-internal IP (e.g. 10.20.30.1/32). Any +# X-Forwarded-* from a peer outside this list gets dropped before +# ProxyFix sees it. +CAULDRON_TRUSTED_PROXIES= diff --git a/cauldron/server.py b/cauldron/server.py index ef9743b..c5f89ef 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -25,7 +25,7 @@ import hmac import ipaddress from datetime import date, datetime, timedelta from functools import wraps -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse import requests from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError @@ -243,22 +243,34 @@ def create_app() -> Flask: # starts-with `https://cauldron.sulkta.com`. Switched to parsed- # origin equality so the host comparison is byte-exact at the # netloc boundary. - _expected_origin = "" - if cfg.base_url: - _bp = urlparse(cfg.base_url) - if _bp.scheme and _bp.netloc: - _expected_origin = f"{_bp.scheme}://{_bp.netloc}" - def _origin_of(url: str) -> str: + """RFC-normalized origin: lowercase scheme + lowercase host, + plus port unless it's the scheme's default. 3rd-pass audit fix + CVE-NEW3-3 (2026-05-02 PM): the prior byte-equality compare + could false-reject browsers that send `Origin: https://Cauldron.SULKTA.com` + (some preserve case in netloc) or `https://x.com:443` against a + bare `https://x.com` base. urlparse already lowercases scheme but + NOT host, and doesn't drop default ports.""" if not url: return "" try: p = urlparse(url) except Exception: return "" - if not p.scheme or not p.netloc: + if not p.scheme or not p.hostname: return "" - return f"{p.scheme}://{p.netloc}" + scheme = p.scheme.lower() + host = p.hostname.lower() + try: + port = p.port + except ValueError: + return "" + default_ports = {"http": 80, "https": 443} + if port is None or port == default_ports.get(scheme): + return f"{scheme}://{host}" + return f"{scheme}://{host}:{port}" + + _expected_origin = _origin_of(cfg.base_url) if cfg.base_url else "" @app.before_request def _csrf_origin_guard(): @@ -514,10 +526,22 @@ def create_app() -> Flask: return "/me" # Allow only a strict path charset. Anything weirder lands at /me. # Path component is everything before the optional `?` / `#`. + # `%` is allowed for percent-encoded chars (3rd-pass audit fix + # CODE3-3, 2026-05-02 PM) so paths like /recipes/spaghetti%20bol + # don't silently land at /me. Defense-in-depth: percent-decode + # the path and re-validate so encoded path-traversal `%2e%2e/` + # is still caught. path = p.path or "/" for ch in path: - if not (ch.isalnum() or ch in "-_./"): + if not (ch.isalnum() or ch in "-_./%"): return "/me" + # Reject any encoded form of `..` or `//` that survives decode + try: + decoded = unquote(path) + except Exception: + return "/me" + if "//" in decoded or "/../" in decoded or decoded.endswith("/..") or decoded.startswith("/.."): + return "/me" return nxt @app.get("/login") @@ -585,14 +609,22 @@ def create_app() -> Flask: return ("missing sub/email in userinfo", 400) db.upsert_user(sub=sub, email=email, display_name=name) + # Capture+revalidate next BEFORE clearing the session — clear + # drops every pre-auth key including post_login_next. + nxt = _safe_next(session.pop("post_login_next", None)) + # Drop ALL pre-auth session state on login. Flask's signed-cookie + # session is just a serialized dict in the cookie body, but + # carrying any pre-auth key into the authenticated state is the + # session-fixation/contamination shape best-practice asks us to + # avoid. 3rd-pass audit fix INFO3-2 (2026-05-02 PM). + session.clear() 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 - # Re-validate post_login_next at consumption (CVE-NEW-3 fix). - return redirect(_safe_next(session.pop("post_login_next", None))) + return redirect(nxt) @app.post("/logout") def logout(): diff --git a/cauldron/templates/discover.html b/cauldron/templates/discover.html index d4f10ca..bf77196 100644 --- a/cauldron/templates/discover.html +++ b/cauldron/templates/discover.html @@ -19,8 +19,9 @@ .dcard { background:var(--bg-2); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; } .dcard .img { width:100%; aspect-ratio: 16/10; - background:var(--bg-1) center/cover no-repeat; - border-bottom:1px solid var(--line); } + background:var(--bg-1); + border-bottom:1px solid var(--line); + display:block; object-fit:cover; } .dcard .img.placeholder { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:36px; } .dcard .body { padding:12px 14px; flex:1; display:flex; @@ -218,8 +219,24 @@ // the import button is correct in either case. const klass = 'dcard ' + (r.imported_in_my_group ? 'imported' : r.status === 'rejected' ? 'rejected' : ''); - const imgHtml = imgUrl - ? `
` + // Render the recipe image as