audit-fixes: 3rd-pass LOW/INFO sweep (CSS injection, Origin RFC, next charset, env doc, session clear)
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 <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 — 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.
This commit is contained in:
parent
32a570b9d4
commit
9f261e6b9e
3 changed files with 89 additions and 16 deletions
24
.env.example
24
.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=
|
||||
|
|
|
|||
|
|
@ -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,9 +526,21 @@ 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
|
||||
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? `<div class="img" style="background-image:url('${_esc(imgUrl)}')"></div>`
|
||||
// Render the recipe image as <img>, 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 <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>`;
|
||||
let actionsHtml = '';
|
||||
if(r.imported_in_my_group){
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue