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. # Independent toggle from base_url so dev/staging can override.
behind_tls: bool 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: def load() -> Config:
return Config( return Config(
@ -89,4 +101,9 @@ def load() -> Config:
), ),
base_url=os.environ.get("CAULDRON_BASE_URL", "").rstrip("/"), base_url=os.environ.get("CAULDRON_BASE_URL", "").rstrip("/"),
behind_tls=os.environ.get("CAULDRON_BEHIND_TLS", "false").lower() in ("1", "true", "yes"), 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 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 # Verification pass: re-check contains.* booleans with a strict
# prompt. Catches false-positives like "pork=true on sweet potatoes" # prompt. Catches false-positives like "pork=true on sweet potatoes"
# that the conservative-default rule produces. Best-effort — # 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 POST /api/sterilize/apply/<slug> (admin bearer) v0.1 sterilizer
""" """
import hmac import hmac
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 urlparse
@ -77,12 +78,66 @@ def create_app() -> Flask:
MAX_CONTENT_LENGTH=1 * 1024 * 1024, MAX_CONTENT_LENGTH=1 * 1024 * 1024,
) )
# Honor X-Forwarded-Proto/Host/For from the rackham reverse proxy # X-Forwarded-* trust chain: parse the trusted-proxy CIDR list once
# so request.is_secure / request.host reflect the public-facing TLS # at boot. Empty = trust nothing → ProxyFix is NOT enabled and any
# endpoint, not the OpenVPN-internal :7790. One hop trusted (audit # X-Forwarded-* headers from any peer get stripped before they reach
# CVE-J6, ProxyFix recommendation). # the app. Audit CVE-NEW-6 (2026-05-02 PM 2nd-pass): the prior
if cfg.behind_tls: # `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 = 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 # Apply migrations on startup
applied = db.migrate() applied = db.migrate()
@ -364,6 +419,14 @@ def create_app() -> Flask:
return local_id return local_id
def current_household_id() -> int | None: 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") u = session.get("user")
if not u: if not u:
return None return None