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 `'` 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:
parent
5c60b7a115
commit
fdd1102a6f
2 changed files with 106 additions and 39 deletions
|
|
@ -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})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue