feat(auth): forged browser requests are refused (#5031)
#5031 "Forged-request protection: SameSite cookie, per-session CSRF token on every non-GET, HTMX header". A page on another site could make a logged-in browser POST to Steward with the session cookie attached. - steward/auth/csrf.py: a per-session token, checked in one before_request for every POST/PUT/PATCH/DELETE (header X-CSRF-Token or form field csrf_token, constant-time compare). The bearer-authenticated host-agent ingest is exempt; /mcp never reaches Quart. - Every POST form renders {{ csrf_field() }}; base.html carries the token in a meta tag and adds it to every HTMX request; the dashboard's fetch() calls send it. - The session cookie is SameSite=Lax; login issues a fresh token. - PUBLIC_BASE_URL is now loaded from Settings at startup (it was only set when Settings → General was saved). - tests/test_csrf.py: refusal, acceptance, rotation, the ingest exemption, and a guard that every POST form in the templates carries the field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,9 @@ def create_app(
|
||||
PLUGIN_DIRS=bootstrap["plugin_dirs"],
|
||||
PLUGIN_INSTALL_DIR=bootstrap["plugin_install_dir"],
|
||||
TESTING=testing,
|
||||
# Lax keeps the session cookie off cross-site POSTs and iframes; the
|
||||
# CSRF token below is the check that does not depend on the browser.
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
)
|
||||
|
||||
# ── 2. Init DB engine ──────────────────────────────────────────────────────
|
||||
@@ -94,6 +97,7 @@ def create_app(
|
||||
SESSION_LIFETIME_HOURS=_settings.get("session.lifetime_hours", 8),
|
||||
DATA_RETENTION_DAYS=_settings.get("data.retention_days", 90),
|
||||
MONITORS_POLL_INTERVAL=_settings.get("monitors.poll_interval_seconds", 60),
|
||||
PUBLIC_BASE_URL=_settings.get("general.public_base_url", ""),
|
||||
SMTP=to_smtp_cfg(_settings),
|
||||
WEBHOOK=to_webhook_cfg(_settings),
|
||||
ANSIBLE=to_ansible_cfg(_settings),
|
||||
@@ -108,6 +112,7 @@ def create_app(
|
||||
SESSION_LIFETIME_HOURS=8,
|
||||
DATA_RETENTION_DAYS=90,
|
||||
MONITORS_POLL_INTERVAL=60,
|
||||
PUBLIC_BASE_URL="",
|
||||
SMTP={},
|
||||
WEBHOOK={},
|
||||
ANSIBLE={},
|
||||
@@ -125,6 +130,14 @@ def create_app(
|
||||
lambda value, kind: _ts_for(value, kind, app.config.get("THRESHOLDS", {}))
|
||||
)
|
||||
|
||||
# Forged-request protection: every POST form renders csrf_field(), HTMX and
|
||||
# fetch() send csrf_token() as X-CSRF-Token, and one check refuses any
|
||||
# unsafe request without it (steward/auth/csrf.py).
|
||||
from .auth import csrf as _csrf
|
||||
app.jinja_env.globals["csrf_token"] = _csrf.csrf_token
|
||||
app.jinja_env.globals["csrf_field"] = _csrf.csrf_field
|
||||
app.before_request(_csrf.check_request)
|
||||
|
||||
# ── 5. Plugin migrations ───────────────────────────────────────────────────
|
||||
# Step 3 already applied all plugin migrations via discover_all_plugin_migration_dirs,
|
||||
# so this is a no-op on normal startup. We still run it with all discovered dirs so
|
||||
|
||||
Reference in New Issue
Block a user