From 9f261e6b9e2c077598535e987a8a07bac309b29f Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 2 May 2026 17:58:37 -0700 Subject: [PATCH] audit-fixes: 3rd-pass LOW/INFO sweep (CSS injection, Origin RFC, next charset, env doc, session clear) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb requested all the small ones land before LAN testing. discover.html CVE-NEW3-2 (LOW): switched recipe card image from a CSS background-image:url('${_esc(url)}') to a plain element. Recipe image_url is scraped from JSON-LD on third-party pages — a malicious page could return an image_url crafted to close the CSS url(...) string and inject layout-breaking CSS. With the URL stays in HTML-attribute context end-to-end where _esc is sufficient. Also adds defense-in-depth: validate URL parses as http(s) before rendering, fall through to placeholder otherwise, and set referrerpolicy=no-referrer so we don't leak our path to image hosts. CSS for .dcard .img widened with object-fit:cover so img and div both center/cover correctly. server.py CVE-NEW3-3 (LOW): _origin_of() now lowercases scheme AND host (urlparse only does scheme), and drops scheme-default ports (:80/:443) so `https://x.com:443` matches `https://x.com`. Closes a false-reject path on browsers that preserve case in Origin headers, or non-canonical CAULDRON_BASE_URL values. Not a bypass — false-reject robustness only — but cheap to fix and operationally important. server.py CODE3-3 (LOW): _safe_next() now allows `%` in the path charset so percent-encoded paths (e.g. /recipes/spaghetti%20bol) don't silently land at /me. Defense-in-depth: also percent-decode the path and reject if the decoded form contains `..` traversal or `//` (encoded forms of the same patterns the front-of-function reject already). server.py INFO3-2: auth_callback now does session.clear() before setting session["user"]. Capture+revalidate `next` BEFORE the clear so we don't drop our own redirect target. Drops every pre-auth key on login — defense-in-depth against session-state contamination if anything else ever lands in pre-auth session. .env.example INFO3-1: added CAULDRON_ADMIN_SUBS, CAULDRON_BASE_URL, CAULDRON_BEHIND_TLS, CAULDRON_TRUSTED_PROXIES with comments explaining what each one gates. Defaults are the safe-LAN set. Holding for public deploy — Cobb running LAN tests for a few days. INFO3-3 (rate limit) intentionally NOT addressed in code: the audit notes this as architecturally a proxy-layer concern (rackham vhost), not in-process. Rolled into the public-deploy commit when the vhost work lands. INFO3-4 (security primitive test coverage) deferred — separate test- sweep PR, doesn't block deploy. --- .env.example | 24 ++++++++++++++ cauldron/server.py | 56 +++++++++++++++++++++++++------- cauldron/templates/discover.html | 25 +++++++++++--- 3 files changed, 89 insertions(+), 16 deletions(-) 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 , not as a CSS background-image: + // url('...') value. The CSS-context rendering used to be a defacement + // surface — `_esc()` HTML-encodes `'`, but the HTML parser decodes + // `'` BEFORE handing the attribute value to the CSS parser, so a + // crafted scraped image_url could close the url(...) string and inject + // arbitrary CSS rules into the discover grid. With the + // URL stays in HTML-attribute context end-to-end and `_esc` is + // sufficient. Audit CVE-NEW3-2 fix (2026-05-02 PM 3rd-pass). + // Defense-in-depth: only render the image element if the URL parses + // as a well-formed http(s) URL — anything else falls back to the + // placeholder. + let safeImg = ''; + try { + const u = new URL(imgUrl); + if (u.protocol === 'http:' || u.protocol === 'https:') safeImg = u.href; + } catch(_) { /* not a parseable URL → no image */ } + const imgHtml = safeImg + ? `` : `
🍴
`; let actionsHtml = ''; if(r.imported_in_my_group){