diff --git a/plugins/docker/templates/docker/disk.html b/plugins/docker/templates/docker/disk.html index 988bd95..806b68f 100644 --- a/plugins/docker/templates/docker/disk.html +++ b/plugins/docker/templates/docker/disk.html @@ -54,19 +54,19 @@
+ onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
+ onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
+ onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
diff --git a/plugins/host_agent/templates/panel.html b/plugins/host_agent/templates/panel.html index 12ba1cb..7223e32 100644 --- a/plugins/host_agent/templates/panel.html +++ b/plugins/host_agent/templates/panel.html @@ -20,17 +20,17 @@ Full metrics → {% if session.user_role == 'admin' %} {% if ansible_available and target %} -
+ {{ csrf_field() }}
{% endif %}
+ onsubmit="return confirm('Rotate token? The agent stops reporting until reinstalled/updated with the new token.');">{{ csrf_field() }}
+ onsubmit="return confirm('Remove this agent registration? Metrics stop until re-registered.');">{{ csrf_field() }}
{% endif %} @@ -49,7 +49,7 @@ Connects over a one-time bootstrap user + password (not stored).

+ style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
@@ -96,7 +96,7 @@ border-radius:6px;padding:0.7rem 0.9rem;display:flex;align-items:center;gap:0.8rem;flex-wrap:wrap;"> ⚠ No managed SSH key yet — Steward needs one to log into hosts. - + {{ csrf_field() }} @@ -115,7 +115,7 @@

{# Provision: brand-new host (creates steward account + key over a one-time password) #}
+ style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:0.75rem;">{{ csrf_field() }}
Provision (first contact — fresh host):
@@ -129,7 +129,7 @@ {# Install: host already has the steward account (managed key works) #} -
+ {{ csrf_field() }} Already provisioned? @@ -138,7 +138,7 @@ {% if reg and session.user_role == 'admin' %} + onsubmit="return confirm('Clear this pending registration? Its token is discarded.');">{{ csrf_field() }}
{% endif %} diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 93cf0d4..fd429a7 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -23,7 +23,7 @@
+ style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
@@ -63,7 +63,7 @@ No managed SSH key yet — Steward needs one to log into hosts as steward. - + {{ csrf_field() }} @@ -85,7 +85,7 @@ agent. Run this once per host.

+ style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
{{ scope_select }}
@@ -111,7 +111,7 @@ mints a fresh token, and installs the agent. No curl | sh needed.

+ style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
{{ scope_select }}
@@ -129,7 +129,7 @@ so identity is preserved.

+ style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
{{ scope_select }}
@@ -156,12 +156,12 @@
+ onsubmit="return confirm('Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.');">{{ csrf_field() }}
+ onsubmit="return confirm('Delete this host registration? The agent will be unable to report metrics until re-registered.');">{{ csrf_field() }}
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' %}
+ onsubmit='return confirm({{ ("Revoke “" ~ t.name ~ "”? Any client using it stops working immediately.")|tojson }})'>{{ csrf_field() }}
{% endif %} @@ -79,7 +79,7 @@
Create a token
-
+ {{ csrf_field() }}
{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} {% if state.state.value == 'firing' %} - + {{ csrf_field() }} {% endif %} @@ -83,12 +83,12 @@ {{ rule.consecutive_failures_required }} Edit -
+ {{ csrf_field() }}
-
+ {{ csrf_field() }}
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 %} -
+ {{ csrf_field() }}
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 @@

New Maintenance Window

-
+ {{ csrf_field() }}
diff --git a/steward/templates/alerts/rules_form.html b/steward/templates/alerts/rules_form.html index 10b657d..c9ffb3e 100644 --- a/steward/templates/alerts/rules_form.html +++ b/steward/templates/alerts/rules_form.html @@ -6,7 +6,7 @@

{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}

- + {{ csrf_field() }}
diff --git a/steward/templates/ansible/browse.html b/steward/templates/ansible/browse.html index d133869..a7cea6d 100644 --- a/steward/templates/ansible/browse.html +++ b/steward/templates/ansible/browse.html @@ -28,7 +28,7 @@ {% if view_editable and session.user_role == 'admin' %} Edit + onsubmit="return confirm('Delete {{ view_path }}?');">{{ csrf_field() }} diff --git a/steward/templates/ansible/inventory/group_detail.html b/steward/templates/ansible/inventory/group_detail.html index c5373ff..2e21ec6 100644 --- a/steward/templates/ansible/inventory/group_detail.html +++ b/steward/templates/ansible/inventory/group_detail.html @@ -7,7 +7,7 @@

Group: {{ group.name }}

- +{{ csrf_field() }}

Settings

diff --git a/steward/templates/ansible/inventory/groups.html b/steward/templates/ansible/inventory/groups.html index 5a1126b..7288bbd 100644 --- a/steward/templates/ansible/inventory/groups.html +++ b/steward/templates/ansible/inventory/groups.html @@ -12,7 +12,7 @@

New Group

- + {{ csrf_field() }}
@@ -38,7 +38,7 @@ {{ grp.targets | length }} Edit - + {{ csrf_field() }} diff --git a/steward/templates/ansible/inventory/target_detail.html b/steward/templates/ansible/inventory/target_detail.html index a2d4df6..e6e4754 100644 --- a/steward/templates/ansible/inventory/target_detail.html +++ b/steward/templates/ansible/inventory/target_detail.html @@ -7,7 +7,7 @@

Target: {{ target.name }}

-
+{{ csrf_field() }}

Connection

diff --git a/steward/templates/ansible/inventory/targets.html b/steward/templates/ansible/inventory/targets.html index 750e6ab..874563f 100644 --- a/steward/templates/ansible/inventory/targets.html +++ b/steward/templates/ansible/inventory/targets.html @@ -12,7 +12,7 @@

New Target

- + {{ csrf_field() }}
@@ -47,7 +47,7 @@ Edit - + {{ csrf_field() }} diff --git a/steward/templates/ansible/playbook_editor.html b/steward/templates/ansible/playbook_editor.html index 8628a2a..a12930e 100644 --- a/steward/templates/ansible/playbook_editor.html +++ b/steward/templates/ansible/playbook_editor.html @@ -15,7 +15,7 @@ automatically; you can also configure a local-dir source under Settings → Ansible.
{% else %} -
+{{ csrf_field() }}
@@ -54,7 +54,7 @@ {% if editing %} + onsubmit="return confirm('Delete {{ playbook_path }}?');">{{ csrf_field() }} diff --git a/steward/templates/ansible/run_detail.html b/steward/templates/ansible/run_detail.html index 0c5ebd6..6ff9ce8 100644 --- a/steward/templates/ansible/run_detail.html +++ b/steward/templates/ansible/run_detail.html @@ -7,7 +7,7 @@

Run Detail

{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %} - + {{ csrf_field() }} {% endif %} diff --git a/steward/templates/ansible/schedules.html b/steward/templates/ansible/schedules.html index 8a6d4be..e49586d 100644 --- a/steward/templates/ansible/schedules.html +++ b/steward/templates/ansible/schedules.html @@ -54,15 +54,15 @@ {% else %}due now{% endif %} -
+ {{ csrf_field() }}
-
+ {{ csrf_field() }}
Edit
+ onsubmit="return confirm('Delete schedule {{ s.name }}?');">{{ csrf_field() }}
@@ -79,7 +79,7 @@ {% set declared_names = (edit_variables or []) | map(attribute='name') | list %}

{{ "Edit schedule" if editing else "New schedule" }}

-
+ {{ csrf_field() }}
diff --git a/steward/templates/auth/login.html b/steward/templates/auth/login.html index a94f7e5..f33282f 100644 --- a/steward/templates/auth/login.html +++ b/steward/templates/auth/login.html @@ -20,7 +20,7 @@
{% endif %} - + {{ csrf_field() }} {% if next_url %}{% endif %}
diff --git a/steward/templates/auth/setup.html b/steward/templates/auth/setup.html index 00c3db7..d5e4929 100644 --- a/steward/templates/auth/setup.html +++ b/steward/templates/auth/setup.html @@ -5,7 +5,7 @@

First-Run Setup

Create the initial admin account.

- + {{ csrf_field() }}
diff --git a/steward/templates/base.html b/steward/templates/base.html index caa3df5..f74a2c6 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -3,6 +3,14 @@ + + {% block title %}Steward{% endblock %} diff --git a/steward/templates/dashboard/_widget_drawer.html b/steward/templates/dashboard/_widget_drawer.html index f5a9ecf..24fb186 100644 --- a/steward/templates/dashboard/_widget_drawer.html +++ b/steward/templates/dashboard/_widget_drawer.html @@ -27,7 +27,7 @@ + style="padding:0.75rem 0.85rem;padding-top:0;border-top:1px solid var(--border);">{{ csrf_field() }}
View -
+ {{ csrf_field() }}
{# Admins can also edit system dashboards #} @@ -115,7 +115,7 @@
New Dashboard
-
+ {{ csrf_field() }}
diff --git a/steward/templates/dashboard/share.html b/steward/templates/dashboard/share.html index f072a58..3bc69a7 100644 --- a/steward/templates/dashboard/share.html +++ b/steward/templates/dashboard/share.html @@ -32,7 +32,7 @@
+ style="margin:0;" onsubmit="return confirm('Revoke this share link?')">{{ csrf_field() }}
@@ -50,7 +50,7 @@
Create Share Link
-
+ {{ csrf_field() }}
diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html index 39f6f3a..1ecb133 100644 --- a/steward/templates/hosts/detail.html +++ b/steward/templates/hosts/detail.html @@ -16,7 +16,7 @@ Edit {% if session.user_role == 'admin' %} + onsubmit="return confirm('Delete host {{ host.name }}? Monitors and history are removed.');">{{ csrf_field() }} {% endif %} @@ -78,7 +78,7 @@
Edit
+ onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
@@ -94,7 +94,7 @@ {% if session.user_role in ['operator', 'admin'] %}
+ Add monitor for this host -
+ {{ csrf_field() }}
@@ -148,7 +148,7 @@ {% endif %}
Edit target - + {{ csrf_field() }} @@ -157,7 +157,7 @@ {% if ansible_sources %}
Run a playbook against this host -
+ {{ csrf_field() }}
diff --git a/steward/templates/hosts/form.html b/steward/templates/hosts/form.html index a6dc34e..f4c7788 100644 --- a/steward/templates/hosts/form.html +++ b/steward/templates/hosts/form.html @@ -6,7 +6,7 @@

{% if host %}Edit Host{% else %}Add Host{% endif %}

-
+ {{ csrf_field() }}
diff --git a/steward/templates/hosts/list.html b/steward/templates/hosts/list.html index c7d72ba..989d8d9 100644 --- a/steward/templates/hosts/list.html +++ b/steward/templates/hosts/list.html @@ -78,7 +78,7 @@ Edit - + {{ csrf_field() }} 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;" %}
-
+ {{ csrf_field() }}
diff --git a/steward/templates/monitors/index.html b/steward/templates/monitors/index.html index 75708cb..eca18ba 100644 --- a/steward/templates/monitors/index.html +++ b/steward/templates/monitors/index.html @@ -16,7 +16,7 @@ {# ── Add monitor ──────────────────────────────────────────────────────────── #}
Add Monitor
- + {{ csrf_field() }}
diff --git a/steward/templates/monitors/rows.html b/steward/templates/monitors/rows.html index 5091180..5a2d3a2 100644 --- a/steward/templates/monitors/rows.html +++ b/steward/templates/monitors/rows.html @@ -54,12 +54,12 @@ {% if session.user_role in ['operator', 'admin'] %}
Edit - + {{ csrf_field() }}
+ onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
diff --git a/steward/templates/settings/_catalog_partial.html b/steward/templates/settings/_catalog_partial.html index 886671d..3c7da8e 100644 --- a/steward/templates/settings/_catalog_partial.html +++ b/steward/templates/settings/_catalog_partial.html @@ -63,7 +63,7 @@ hx-post="/settings/plugins/install/{{ plugin.name }}" hx-target="#catalog-result-{{ plugin.name }}" hx-swap="outerHTML" - style="margin:0;"> + style="margin:0;">{{ csrf_field() }} @@ -79,7 +79,7 @@ hx-post="/settings/plugins/reload/{{ plugin.name }}" hx-target="#catalog-result-{{ plugin.name }}" hx-swap="outerHTML" - style="margin:0;"> + style="margin:0;">{{ csrf_field() }} @@ -90,7 +90,7 @@ hx-post="/settings/plugins/install/{{ plugin.name }}" hx-target="#catalog-result-{{ plugin.name }}" hx-swap="outerHTML" - style="margin:0;"> + style="margin:0;">{{ csrf_field() }} diff --git a/steward/templates/settings/_install_result.html b/steward/templates/settings/_install_result.html index 9ec2532..ba8a9fe 100644 --- a/steward/templates/settings/_install_result.html +++ b/steward/templates/settings/_install_result.html @@ -10,7 +10,7 @@ hx-post="/settings/plugins/restart/" hx-target="#restart-banner" hx-swap="outerHTML" - style="display:inline;margin:0;"> + style="display:inline;margin:0;">{{ csrf_field() }}
+ onsubmit="return confirm('Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.');">{{ csrf_field() }}
{% else %} -
+ {{ csrf_field() }} ed25519 — created server-side, nothing to paste.
@@ -56,7 +56,7 @@ Used by every playbook run (manual and alert-triggered). Stored on the server and never shown again after saving — leave a field blank to keep the current value.

-
+ {{ csrf_field() }}