audit-fixes: 3rd-pass LOW/INFO sweep (CSS injection, Origin RFC, next charset, env doc, session clear)

Alice 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 <img class="img" src=...>
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 <img src=...> 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 — Alice 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 (proxy 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.
This commit is contained in:
Sulkta 2026-05-02 17:58:37 -07:00
parent dfd36fc4d7
commit bfa00d7db7
3 changed files with 89 additions and 16 deletions

View file

@ -38,3 +38,27 @@ DB_PASSWORD=
# Fernet master key for at-rest encryption of per-user Mealie tokens. # 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())" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CAULDRON_FERNET_KEY= 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.
# Alice'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.example.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 (the reverse proxy apache → cauldron over
# the container network). 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 reverse-proxy→app deploy,
# set this to the reverse proxy's 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=

View file

@ -25,7 +25,7 @@ import hmac
import ipaddress import ipaddress
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from functools import wraps from functools import wraps
from urllib.parse import urlparse from urllib.parse import unquote, urlparse
import requests import requests
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
@ -243,22 +243,34 @@ def create_app() -> Flask:
# starts-with `https://cauldron.example.com`. Switched to parsed- # starts-with `https://cauldron.example.com`. Switched to parsed-
# origin equality so the host comparison is byte-exact at the # origin equality so the host comparison is byte-exact at the
# netloc boundary. # 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: 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.example.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: if not url:
return "" return ""
try: try:
p = urlparse(url) p = urlparse(url)
except Exception: except Exception:
return "" return ""
if not p.scheme or not p.netloc: if not p.scheme or not p.hostname:
return "" 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 @app.before_request
def _csrf_origin_guard(): def _csrf_origin_guard():
@ -514,10 +526,22 @@ def create_app() -> Flask:
return "/me" return "/me"
# Allow only a strict path charset. Anything weirder lands at /me. # Allow only a strict path charset. Anything weirder lands at /me.
# Path component is everything before the optional `?` / `#`. # 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 "/" path = p.path or "/"
for ch in path: for ch in path:
if not (ch.isalnum() or ch in "-_./"): if not (ch.isalnum() or ch in "-_./%"):
return "/me" 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 return nxt
@app.get("/login") @app.get("/login")
@ -585,14 +609,22 @@ def create_app() -> Flask:
return ("missing sub/email in userinfo", 400) return ("missing sub/email in userinfo", 400)
db.upsert_user(sub=sub, email=email, display_name=name) 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} session["user"] = {"sub": sub, "email": email, "name": name}
# Mark session permanent so PERMANENT_SESSION_LIFETIME (14d) is # Mark session permanent so PERMANENT_SESSION_LIFETIME (14d) is
# honored. Without this, Flask treats the session as a browser- # honored. Without this, Flask treats the session as a browser-
# session cookie (no Expires) and tab-close kills it. Audit # session cookie (no Expires) and tab-close kills it. Audit
# CVE-D2 (2026-05-02). # CVE-D2 (2026-05-02).
session.permanent = True session.permanent = True
# Re-validate post_login_next at consumption (CVE-NEW-3 fix). return redirect(nxt)
return redirect(_safe_next(session.pop("post_login_next", None)))
@app.post("/logout") @app.post("/logout")
def logout(): def logout():

View file

@ -19,8 +19,9 @@
.dcard { background:var(--bg-2); border:1px solid var(--line); .dcard { background:var(--bg-2); border:1px solid var(--line);
border-radius:10px; overflow:hidden; display:flex; flex-direction:column; } border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
.dcard .img { width:100%; aspect-ratio: 16/10; .dcard .img { width:100%; aspect-ratio: 16/10;
background:var(--bg-1) center/cover no-repeat; background:var(--bg-1);
border-bottom:1px solid var(--line); } border-bottom:1px solid var(--line);
display:block; object-fit:cover; }
.dcard .img.placeholder { display:flex; align-items:center; .dcard .img.placeholder { display:flex; align-items:center;
justify-content:center; color:var(--muted); font-size:36px; } justify-content:center; color:var(--muted); font-size:36px; }
.dcard .body { padding:12px 14px; flex:1; display:flex; .dcard .body { padding:12px 14px; flex:1; display:flex;
@ -218,8 +219,24 @@
// the import button is correct in either case. // the import button is correct in either case.
const klass = 'dcard ' + (r.imported_in_my_group ? 'imported' : const klass = 'dcard ' + (r.imported_in_my_group ? 'imported' :
r.status === 'rejected' ? 'rejected' : ''); r.status === 'rejected' ? 'rejected' : '');
const imgHtml = imgUrl // Render the recipe image as <img>, not as a CSS background-image:
? `<div class="img" style="background-image:url('${_esc(imgUrl)}')"></div>` // url('...') value. The CSS-context rendering used to be a defacement
// surface — `_esc()` HTML-encodes `'`, but the HTML parser decodes
// `&#39;` 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 <img src=...> 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
? `<img class="img" src="${_esc(safeImg)}" alt="" loading="lazy" referrerpolicy="no-referrer">`
: `<div class="img placeholder">🍴</div>`; : `<div class="img placeholder">🍴</div>`;
let actionsHtml = ''; let actionsHtml = '';
if(r.imported_in_my_group){ if(r.imported_in_my_group){