security: fix 2 CRITs surfaced by 2nd-pass audit (CSRF prefix bypass + slug XSS)

Both findings are net-new — first audit ran before the CSRF guard existed,
and the picks→onclick interpolation predates the discover/scrape work that
expanded slug shape. Verified by grep+read.

server.py CVE-NEW-1 (CRIT): @before_request CSRF guard used
`origin.startswith(cfg.base_url)`. With CAULDRON_BASE_URL=https://cauldron.sulkta.com,
an attacker-registered `cauldron.sulkta.com.evil.com` produces an
Origin header that startswith the configured base — guard passes,
A1 fix trivially defeated. Replaced with parsed-origin equality
(scheme+netloc, byte-exact at the netloc boundary). Pre-parse the
expected origin once at app boot rather than per-request.

server.py + picks.html CVE-NEW-2 (CRIT): /api/picks/<slug> took
slug from URL path with NO validation against the household's
recipe index. picks.html then interpolated slug into
`onclick="removePick('{{ slug }}', this)"` — Jinja escapes `'` to
`&#39;` but HTML attribute decoding returns bare `'` to the JS
engine, so a slug like `x'),alert(1);//` round-trips DB → template
→ JS execution for every household member who loads /picks. Two-
layer fix:
  - add_pick now requires slug ∈ db.find_indexed_recipe(hid, slug),
    returning 404 on miss. Also closes prompt-injection-via-poison-
    slug into the planner. Indexed name is trusted over client-
    supplied name (defense in depth on the name field too).
  - picks.html switches to a delegated click listener reading
    slug from the parent <li>'s data-slug attribute. Slug never
    lands inside a JS string literal in HTML.

server.py CVE-NEW-3 (HIGH): _safe_next() helper centralizes the
post-login redirect validation. Applied at BOTH /login stash time
AND /auth/callback consumption time so a future writer of
session['post_login_next'] can't bypass. Strict path charset
[A-Za-z0-9_./-], rejects scheme/netloc, rejects `//`, `/\`.
This commit is contained in:
Kayos 2026-05-02 16:48:21 -07:00
parent 5c60b7a115
commit fdd1102a6f
2 changed files with 106 additions and 39 deletions

View file

@ -24,6 +24,7 @@ Routes (current):
import hmac import hmac
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
import requests import requests
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
@ -177,29 +178,50 @@ def create_app() -> Flask:
# SAMESITE=Lax alone doesn't cover same-site subdomain CSRF (a # SAMESITE=Lax alone doesn't cover same-site subdomain CSRF (a
# compromised *.sulkta.com page POSTing to cauldron.sulkta.com # compromised *.sulkta.com page POSTing to cauldron.sulkta.com
# carries cookies). When CAULDRON_BASE_URL is set, every state- # carries cookies). When CAULDRON_BASE_URL is set, every state-
# mutating request must have an Origin (or Referer) that starts # mutating request must EXACTLY match origin (scheme+host+port).
# with that base. Bearer-token API calls are exempt — no cookie # Bearer-token API calls are exempt — no cookie means no CSRF
# means no CSRF surface. Pure-GET/HEAD/OPTIONS are exempt. # surface. Pure-GET/HEAD/OPTIONS are exempt.
#
# 2nd-pass audit fix (2026-05-02 PM, CVE-NEW-1): the original guard
# used `startswith(cfg.base_url)` which is bypassable by an attacker
# registering `cauldron.sulkta.com.evil.com` — its Origin string
# 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:
if not url:
return ""
try:
p = urlparse(url)
except Exception:
return ""
if not p.scheme or not p.netloc:
return ""
return f"{p.scheme}://{p.netloc}"
@app.before_request @app.before_request
def _csrf_origin_guard(): def _csrf_origin_guard():
if not cfg.base_url: if not _expected_origin:
return # LAN-only deploy — same-origin is implicit return # LAN-only deploy — same-origin is implicit
if request.method in ("GET", "HEAD", "OPTIONS"): if request.method in ("GET", "HEAD", "OPTIONS"):
return return
# Bearer-auth callers don't carry cookies → no CSRF # Bearer-auth callers don't carry cookies → no CSRF
if request.headers.get("Authorization", "").startswith("Bearer "): if request.headers.get("Authorization", "").startswith("Bearer "):
return return
# Origin is the canonical signal; Referer is a fallback origin_hdr = request.headers.get("Origin") or ""
origin = request.headers.get("Origin") or "" referer_hdr = request.headers.get("Referer") or ""
referer = request.headers.get("Referer") or "" origin_match = origin_hdr and _origin_of(origin_hdr) == _expected_origin
ok = ( referer_match = referer_hdr and _origin_of(referer_hdr) == _expected_origin
(origin and origin.startswith(cfg.base_url)) if not (origin_match or referer_match):
or (referer and referer.startswith(cfg.base_url))
)
if not ok:
app.logger.warning( app.logger.warning(
"csrf reject: method=%s path=%s origin=%r referer=%r", "csrf reject: method=%s path=%s origin=%r referer=%r",
request.method, request.path, origin[:100], referer[:100], request.method, request.path, origin_hdr[:100], referer_hdr[:100],
) )
if request.path.startswith("/api/"): if request.path.startswith("/api/"):
return jsonify({"error": "csrf_origin_mismatch"}), 403 return jsonify({"error": "csrf_origin_mismatch"}), 403
@ -403,16 +425,42 @@ def create_app() -> Flask:
return redirect(url_for("login")) return redirect(url_for("login"))
return redirect(url_for("me")) return redirect(url_for("me"))
def _safe_next(nxt: str | None) -> str:
"""Validate a post-login redirect target is a same-origin local
path. Defense-in-depth open-redirect guard we apply this BOTH
at the /login stash AND at /auth/callback consumption (CVE-NEW-3
audit fix 2026-05-02 PM). The double-check protects against any
future code path that writes session['post_login_next'] outside
of /login, and against percent-encoded path tricks."""
if not nxt:
return "/me"
# Must start with `/` and only `/`. Reject `//foo`, `/\\foo`,
# any scheme/host. Reject if urlparse extracts a netloc.
if not nxt.startswith("/"):
return "/me"
if nxt.startswith("//") or nxt.startswith("/\\"):
return "/me"
try:
p = urlparse(nxt)
except Exception:
return "/me"
if p.scheme or p.netloc:
return "/me"
# Allow only a strict path charset. Anything weirder lands at /me.
# Path component is everything before the optional `?` / `#`.
path = p.path or "/"
for ch in path:
if not (ch.isalnum() or ch in "-_./"):
return "/me"
return nxt
@app.get("/login") @app.get("/login")
def login(): def login():
# Stash where to go after login. Validate same-origin path to # Stash where to go after login. _safe_next closes the open-
# close the open-redirect surface — `next=https://evil.example/...` # redirect surface — `next=https://evil.example/...` would
# would otherwise route an authenticated user to an attacker page # otherwise route an authenticated user to an attacker page
# right after OIDC handshake. Audit F-3a routes 2026-05-02. # right after OIDC handshake.
nxt = request.args.get("next") or "/me" session["post_login_next"] = _safe_next(request.args.get("next"))
if not nxt.startswith("/") or nxt.startswith("//") or nxt.startswith("/\\"):
nxt = "/me"
session["post_login_next"] = nxt
return oauth.cauldron.authorize_redirect(cfg.oidc_redirect_uri) return oauth.cauldron.authorize_redirect(cfg.oidc_redirect_uri)
@app.get("/auth/callback") @app.get("/auth/callback")
@ -463,7 +511,8 @@ def create_app() -> Flask:
# 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
return redirect(session.pop("post_login_next", "/me")) # Re-validate post_login_next at consumption (CVE-NEW-3 fix).
return redirect(_safe_next(session.pop("post_login_next", None)))
@app.post("/logout") @app.post("/logout")
def logout(): def logout():
@ -693,18 +742,26 @@ def create_app() -> Flask:
@require_session @require_session
def add_pick(slug: str): def add_pick(slug: str):
u = session["user"] u = session["user"]
name = (request.json or {}).get("name", "") if request.is_json else request.form.get("name", "") # Validate slug exists in this household's recipe index BEFORE
if not name: # accepting the pin. Without this, a crafted POST to
# Look it up from Mealie if missing # /api/picks/<arbitrary-string> stored junk in cauldron_meal_picks
client = current_user_mealie() # — and `picks.html` interpolated the slug straight into a JS
if client: # `onclick='removePick('{{ slug }}', ...)'` literal, opening a
try: # stored-XSS surface where any household member viewing /picks
r = client.get_recipe(slug) # ran the attacker's JS. Audit CVE-NEW-2 (2026-05-02 PM 2nd-pass).
name = r.get("name") or slug # Also closes the prompt-injection-via-poison-slug vector since
except Exception: # the planner would otherwise pass garbage slugs to Sonnet.
name = slug hid = current_household_id()
else: if hid is None:
name = slug return jsonify({"ok": False, "error": "no_household"}), 400
idx = db.find_indexed_recipe(hid, slug)
if not idx:
return jsonify({"ok": False, "error": "recipe_not_indexed"}), 404
# Trust the indexed name over client-supplied — also closes the
# XSS surface for the `name` field if the template ever interpolates
# it into JS (currently only renders inside `<a>` text where Jinja
# autoescape is sufficient, but defense in depth).
name = idx.get("name") or slug
added = db.add_meal_pick(u["sub"], slug, name) added = db.add_meal_pick(u["sub"], slug, name)
return jsonify({"ok": True, "added": added, "slug": slug}) return jsonify({"ok": True, "added": added, "slug": slug})

View file

@ -26,7 +26,7 @@
<div style="display: flex; justify-content: space-between; align-items: baseline; gap: 14px;"> <div style="display: flex; justify-content: space-between; align-items: baseline; gap: 14px;">
<a href="/recipes/{{ p.slug }}" style="flex: 1; color: var(--bone); font-family: var(--serif); font-size: 1.05em; border: none;">{{ p.name }}</a> <a href="/recipes/{{ p.slug }}" style="flex: 1; color: var(--bone); font-family: var(--serif); font-size: 1.05em; border: none;">{{ p.name }}</a>
{% if p.mine %} {% if p.mine %}
<button class="btn" type="button" onclick="removePick('{{ p.slug }}', this)" style="font-size: 11px; padding: .35em .9em;">unpin</button> <button class="btn js-unpin" type="button" style="font-size: 11px; padding: .35em .9em;">unpin</button>
{% endif %} {% endif %}
</div> </div>
<div style="margin-top: 4px; color: var(--muted); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; font-family: var(--mono);"> <div style="margin-top: 4px; color: var(--muted); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; font-family: var(--mono);">
@ -53,18 +53,28 @@
{% endif %} {% endif %}
<script> <script>
async function removePick(slug, btn) { // Delegated unpin listener — slug is read from the parent <li>'s
// data-slug attribute (HTML-attribute context, autoescaped by Jinja),
// never interpolated into a JS string literal inside HTML. Audit
// CVE-NEW-2 fix 2026-05-02 PM: the prior `onclick="removePick('{{ slug }}',...)"`
// pattern was a stored-XSS surface because HTML attribute decoding
// returns the bare `'` to the JS engine.
document.getElementById('picks-list')?.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.js-unpin');
if (!btn) return;
const li = btn.closest('li');
const slug = li?.dataset?.slug;
if (!slug) return;
btn.disabled = true; btn.textContent = '…'; btn.disabled = true; btn.textContent = '…';
try { try {
const r = await fetch(`/api/picks/${encodeURIComponent(slug)}`, { method: 'DELETE' }); const r = await fetch(`/api/picks/${encodeURIComponent(slug)}`, { method: 'DELETE' });
if (!r.ok) throw new Error(r.status); if (!r.ok) throw new Error(r.status);
const li = btn.closest('li'); li.remove();
if (li) li.remove();
if (!document.querySelectorAll('#picks-list li').length) location.reload(); if (!document.querySelectorAll('#picks-list li').length) location.reload();
} catch (e) { } catch (e) {
btn.disabled = false; btn.textContent = 'unpin'; btn.disabled = false; btn.textContent = 'unpin';
} }
} });
</script> </script>
{% endblock %} {% endblock %}