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:
parent
946abd0322
commit
291fea0201
3 changed files with 93 additions and 5 deletions
|
|
@ -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()
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue