@@ -129,7 +129,7 @@
so identity is preserved.
@@ -156,12 +156,12 @@
|
diff --git a/steward/app.py b/steward/app.py
index 9b302d4..7b1041b 100644
--- a/steward/app.py
+++ b/steward/app.py
@@ -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
diff --git a/steward/auth/csrf.py b/steward/auth/csrf.py
new file mode 100644
index 0000000..93a1dcc
--- /dev/null
+++ b/steward/auth/csrf.py
@@ -0,0 +1,63 @@
+"""Forged-request (CSRF) protection for the browser side of Steward.
+
+A page on another site can make a logged-in browser POST to Steward, and the
+browser sends Steward's session cookie with it. Every state-changing request
+from a browser therefore has to carry a token that only Steward's own pages
+know: a random value kept in the session, written into each form by
+`csrf_field()` and sent by HTMX and fetch() as the `X-CSRF-Token` header (set
+once on in base.html).
+
+One check in a before_request covers every route, so a new route cannot
+forget it. Callers that authenticate with a bearer token instead of the
+session cookie (the host agent's ingest; /mcp never reaches Quart) are exempt:
+a forged browser request cannot carry their token.
+"""
+from __future__ import annotations
+
+import hmac
+import secrets
+
+from markupsafe import Markup, escape
+from quart import Response, request, session
+
+SESSION_KEY = "_csrf"
+FORM_FIELD = "csrf_token"
+HEADER = "X-CSRF-Token"
+UNSAFE_METHODS = ("POST", "PUT", "PATCH", "DELETE")
+# Bearer-authenticated endpoints: no session cookie, nothing to forge.
+EXEMPT_PATHS = frozenset({"/plugins/host_agent/ingest"})
+
+
+def csrf_token() -> str:
+ """This session's token, created on first use."""
+ token = session.get(SESSION_KEY)
+ if not token:
+ token = session[SESSION_KEY] = secrets.token_urlsafe(32)
+ return token
+
+
+def csrf_field() -> Markup:
+ """The hidden input every POST form carries."""
+ return Markup(f'
')
+
+
+def rotate() -> None:
+ """Drop the token, so a new one is issued — at login, where the session
+ changes hands."""
+ session.pop(SESSION_KEY, None)
+
+
+async def check_request() -> Response | None:
+ """before_request hook: refuse an unsafe request without the session's token."""
+ if request.method not in UNSAFE_METHODS or request.path in EXEMPT_PATHS:
+ return None
+ expected = session.get(SESSION_KEY)
+ sent = request.headers.get(HEADER, "")
+ if not sent:
+ form = await request.form
+ sent = form.get(FORM_FIELD, "")
+ if expected and sent and hmac.compare_digest(expected, sent):
+ return None
+ return Response(
+ "This form has expired or did not come from Steward. Reload the page and try again.",
+ status=400, content_type="text/plain; charset=utf-8")
diff --git a/steward/auth/middleware.py b/steward/auth/middleware.py
index e1b06c9..89f9b0d 100644
--- a/steward/auth/middleware.py
+++ b/steward/auth/middleware.py
@@ -82,7 +82,9 @@ def require_role(minimum_role: UserRole):
def login_user(user) -> None:
- """Write user identity into the session."""
+ """Write user identity into the session, with a fresh CSRF token."""
+ from steward.auth.csrf import rotate
+ rotate()
session["user_id"] = user.id
session["user_role"] = user.role.value
session["username"] = user.username
diff --git a/steward/templates/ai_access/index.html b/steward/templates/ai_access/index.html
index e4fac24..cbe2175 100644
--- a/steward/templates/ai_access/index.html
+++ b/steward/templates/ai_access/index.html
@@ -59,7 +59,7 @@
{% if row.state == 'active' %}
{% endif %}
@@ -79,7 +79,7 @@
|
{% if state.state.value == 'firing' %}
-
{% endif %}
@@ -83,12 +83,12 @@
| {{ rule.consecutive_failures_required }} |
Edit
-
-
diff --git a/steward/templates/alerts/maintenance.html b/steward/templates/alerts/maintenance.html
index 1126e67..4832abf 100644
--- a/steward/templates/alerts/maintenance.html
+++ b/steward/templates/alerts/maintenance.html
@@ -59,7 +59,7 @@
{% endif %}
|
-
diff --git a/steward/templates/alerts/maintenance_form.html b/steward/templates/alerts/maintenance_form.html
index 860102b..841c03a 100644
--- a/steward/templates/alerts/maintenance_form.html
+++ b/steward/templates/alerts/maintenance_form.html
@@ -6,7 +6,7 @@
|
Edit
-
diff --git a/steward/templates/monitors/edit.html b/steward/templates/monitors/edit.html
index 4df8b02..bac031d 100644
--- a/steward/templates/monitors/edit.html
+++ b/steward/templates/monitors/edit.html
@@ -12,7 +12,7 @@
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
- |