audit-fixes: trusted-proxy X-Forwarded-* gate, enrich heartbeat, hid convention

server.py + config.py CVE-NEW-6 (MED): introduces CAULDRON_TRUSTED_PROXIES
CIDR-list env var and a _StripUntrustedForwardedHeaders WSGI middleware
that wraps the outside of ProxyFix. Behavior:
  - empty list (default, dev/LAN) → ProxyFix is NOT enabled, AND
    incoming X-Forwarded-* / Forwarded headers are stripped from
    every request. Even on a LAN HTTP deploy nobody can spoof
    request.is_secure / request.host downstream.
  - non-empty list (e.g. 192.168.50.1/32 for rackham over WireGuard)
    → strip middleware drops X-Forwarded-* unless REMOTE_ADDR is in
    a listed CIDR; ProxyFix then trusts the surviving headers.

Closes the audit's concern that the prior `if cfg.behind_tls: ProxyFix`
trusted X-Forwarded-* from any peer that could reach :7790 — sibling
containers on the sulkta docker network could spoof scheme/host since
gunicorn binds 0.0.0.0:7790 and Docker bridges resolve container DNS
internally. The trust anchor is now the peer IP, not just the hostname
the request claims to come from.

Decoupling trusted_proxies from behind_tls also handles deploy shapes
where TLS is terminated by something that doesn't forward X-Forwarded-*
(SSL passthrough, etc).

server.py CODE-4 (MED, doc-only): added docstring on
current_household_id() declaring it the canonical hid source for
session-auth routes. Admin-bearer endpoints legitimately derive hid
from started_by_sub (the bearer is the trust anchor); session-auth
endpoints must never accept hid from request body. No code change —
the current code already follows this convention; the docstring
prevents future drift.

enrich_recipes.py CODE-5 (MED): added an explicit progress heartbeat
between forge.enrich_recipe() and forge.verify_allergens() so a
slow allergen-verification pass on a complex recipe can't push
last_progress_at past db.fail_stuck_enrich_jobs's 15-min stale gate.
Without this, two ~3-4-min Sonnet calls back-to-back could straddle
the gate and a still-alive job would be incorrectly reaped at the
next worker restart.
This commit is contained in:
Kayos 2026-05-02 17:41:54 -07:00
parent 946abd0322
commit 291fea0201
3 changed files with 93 additions and 5 deletions

View file

@ -54,6 +54,18 @@ class Config:
# Independent toggle from base_url so dev/staging can override.
behind_tls: bool
# Comma-separated list of CIDRs (or single IPs) whose X-Forwarded-*
# headers we trust. Empty = trust nothing → ProxyFix is NOT enabled
# and incoming X-Forwarded-* headers from any peer are stripped before
# they reach the app. When non-empty, only the listed peers can set
# the perceived scheme/host/port. Audit CVE-NEW-6 (2026-05-02 PM):
# the prior `if cfg.behind_tls: ProxyFix(...)` trusted X-Forwarded-*
# from ANY peer that could reach :7790 — including other containers
# on the sulkta docker network, since gunicorn binds 0.0.0.0:7790.
# An attacker on a sibling container could spoof X-Forwarded-Proto
# and have request.is_secure return True even on plain HTTP.
trusted_proxies: tuple[str, ...]
def load() -> Config:
return Config(
@ -89,4 +101,9 @@ def load() -> Config:
),
base_url=os.environ.get("CAULDRON_BASE_URL", "").rstrip("/"),
behind_tls=os.environ.get("CAULDRON_BEHIND_TLS", "false").lower() in ("1", "true", "yes"),
trusted_proxies=tuple(
s.strip()
for s in os.environ.get("CAULDRON_TRUSTED_PROXIES", "").split(",")
if s.strip()
),
)

View file

@ -132,6 +132,14 @@ def run_enrich(
)
continue
# Heartbeat between Sonnet sub-calls so a slow verify_allergens
# doesn't push last_progress_at past db.fail_stuck_enrich_jobs's
# stale_minutes (15) window. Audit CODE-5 (2026-05-02 PM):
# without this, two ~3-4-min Sonnet calls back-to-back could
# straddle the 15-min staleness gate and a still-alive job
# would be incorrectly reaped at next worker restart.
db.update_enrich_job_progress(job_id, current_slug=slug)
# Verification pass: re-check contains.* booleans with a strict
# prompt. Catches false-positives like "pork=true on sweet potatoes"
# that the conservative-default rule produces. Best-effort —

View file

@ -22,6 +22,7 @@ Routes (current):
POST /api/sterilize/apply/<slug> (admin bearer) v0.1 sterilizer
"""
import hmac
import ipaddress
from datetime import date, datetime, timedelta
from functools import wraps
from urllib.parse import urlparse
@ -77,12 +78,66 @@ def create_app() -> Flask:
MAX_CONTENT_LENGTH=1 * 1024 * 1024,
)
# Honor X-Forwarded-Proto/Host/For from the rackham reverse proxy
# so request.is_secure / request.host reflect the public-facing TLS
# endpoint, not the OpenVPN-internal :7790. One hop trusted (audit
# CVE-J6, ProxyFix recommendation).
if cfg.behind_tls:
# X-Forwarded-* trust chain: parse the trusted-proxy CIDR list once
# at boot. Empty = trust nothing → ProxyFix is NOT enabled and any
# X-Forwarded-* headers from any peer get stripped before they reach
# the app. Audit CVE-NEW-6 (2026-05-02 PM 2nd-pass): the prior
# `if cfg.behind_tls: ProxyFix(...)` trusted X-Forwarded-* from any
# peer that could reach :7790 — including sibling containers on the
# sulkta docker network, since gunicorn binds 0.0.0.0:7790. An
# attacker on a co-located container could spoof X-Forwarded-Proto
# and have request.is_secure return True even on plain HTTP.
_trusted_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
for entry in cfg.trusted_proxies:
try:
_trusted_networks.append(ipaddress.ip_network(entry, strict=False))
except ValueError:
app.logger.warning("CAULDRON_TRUSTED_PROXIES: ignoring invalid entry %r", entry)
def _peer_is_trusted(remote_addr: str) -> bool:
if not remote_addr or not _trusted_networks:
return False
try:
ip = ipaddress.ip_address(remote_addr)
except ValueError:
return False
return any(ip in net for net in _trusted_networks)
class _StripUntrustedForwardedHeaders:
"""WSGI middleware: when the immediate peer (REMOTE_ADDR pre-
ProxyFix) is NOT in CAULDRON_TRUSTED_PROXIES, strip every
X-Forwarded-* and Forwarded environ var so ProxyFix won't act
on attacker-supplied scheme/host/for values. This middleware
wraps the OUTSIDE of ProxyFix so it runs first."""
FORWARDED_KEYS = (
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED_PROTO",
"HTTP_X_FORWARDED_HOST",
"HTTP_X_FORWARDED_PORT",
"HTTP_X_FORWARDED_PREFIX",
"HTTP_FORWARDED",
)
def __init__(self, wsgi_app):
self.wsgi_app = wsgi_app
def __call__(self, environ, start_response):
if not _peer_is_trusted(environ.get("REMOTE_ADDR", "")):
for k in self.FORWARDED_KEYS:
environ.pop(k, None)
return self.wsgi_app(environ, start_response)
if cfg.trusted_proxies:
# Compose: strip(proxyfix(app)). WSGI wraps outside-in, so strip
# runs FIRST (filters environ), then ProxyFix sees only headers
# from trusted peers, then the original Flask app.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
app.wsgi_app = _StripUntrustedForwardedHeaders(app.wsgi_app)
else:
# No trusted proxies configured. Even on a LAN HTTP deploy
# someone could send X-Forwarded-* headers and trick something
# downstream — strip them defensively.
app.wsgi_app = _StripUntrustedForwardedHeaders(app.wsgi_app)
# Apply migrations on startup
applied = db.migrate()
@ -364,6 +419,14 @@ def create_app() -> Flask:
return local_id
def current_household_id() -> int | None:
"""Canonical household_id source for session-authenticated routes.
EVERY session-auth handler that scopes data by household MUST get
its `hid` from this helper, never from request body / query / form
otherwise we open a cross-household read/write surface (audit
CODE-4 convention, 2026-05-02 PM 2nd-pass). Admin-bearer endpoints
legitimately derive hid from `started_by_sub` because the bearer
IS the trust anchor for those calls; that path is documented
separately at the admin endpoints."""
u = session.get("user")
if not u:
return None