diff --git a/cauldron/config.py b/cauldron/config.py index a950a6f..0f4f5bc 100644 --- a/cauldron/config.py +++ b/cauldron/config.py @@ -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() + ), ) diff --git a/cauldron/enrich_recipes.py b/cauldron/enrich_recipes.py index d513323..f1d321d 100644 --- a/cauldron/enrich_recipes.py +++ b/cauldron/enrich_recipes.py @@ -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 — diff --git a/cauldron/server.py b/cauldron/server.py index 9ead02c..184e22a 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -22,6 +22,7 @@ Routes (current): POST /api/sterilize/apply/ (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