feat(auth): forged browser requests are refused (#5031)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 49s
CI / unit (push) Successful in 1m3s
CI / publish (push) Successful in 1m18s

#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:
2026-10-06 10:44:19 -04:00
co-authored by Claude Opus 5.5
parent 1d9ac2b46a
commit 96126b1c83
46 changed files with 272 additions and 90 deletions
+3 -3
View File
@@ -54,19 +54,19 @@
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;"> <div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;">
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;" <form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-msg="Remove all STOPPED containers on {{ g.host_name|e }}? This cannot be undone." data-msg="Remove all STOPPED containers on {{ g.host_name|e }}? This cannot be undone."
onsubmit="return confirm(this.dataset.msg);"> onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
<input type="hidden" name="target" value="containers"> <input type="hidden" name="target" value="containers">
<button type="submit" class="btn btn-sm">Prune stopped containers</button> <button type="submit" class="btn btn-sm">Prune stopped containers</button>
</form> </form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;" <form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-msg="Remove ALL unused images on {{ g.host_name|e }} (docker image prune -a)? Any image not used by a container is deleted." data-msg="Remove ALL unused images on {{ g.host_name|e }} (docker image prune -a)? Any image not used by a container is deleted."
onsubmit="return confirm(this.dataset.msg);"> onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
<input type="hidden" name="target" value="images"> <input type="hidden" name="target" value="images">
<button type="submit" class="btn btn-sm">Prune unused images</button> <button type="submit" class="btn btn-sm">Prune unused images</button>
</form> </form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;" <form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-msg="Run a full system prune on {{ g.host_name|e }}? Removes stopped containers, unused networks, dangling images and build cache." data-msg="Run a full system prune on {{ g.host_name|e }}? Removes stopped containers, unused networks, dangling images and build cache."
onsubmit="return confirm(this.dataset.msg);"> onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
<input type="hidden" name="target" value="system"> <input type="hidden" name="target" value="system">
<button type="submit" class="btn btn-sm btn-danger">System prune…</button> <button type="submit" class="btn btn-sm btn-danger">System prune…</button>
</form> </form>
+8 -8
View File
@@ -20,17 +20,17 @@
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a> <a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
{% if session.user_role == 'admin' %} {% if session.user_role == 'admin' %}
{% if ansible_available and target %} {% if ansible_available and target %}
<form method="post" action="/plugins/host_agent/update" style="margin:0;"> <form method="post" action="/plugins/host_agent/update" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="inventory_scope" value="{{ scope }}"> <input type="hidden" name="inventory_scope" value="{{ scope }}">
<button type="submit" class="btn btn-sm" title="Refresh agent.py + restart (token preserved)">Update agent</button> <button type="submit" class="btn btn-sm" title="Refresh agent.py + restart (token preserved)">Update agent</button>
</form> </form>
{% endif %} {% endif %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/rotate-token" style="margin:0;" <form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/rotate-token" style="margin:0;"
onsubmit="return confirm('Rotate token? The agent stops reporting until reinstalled/updated with the new token.');"> onsubmit="return confirm('Rotate token? The agent stops reporting until reinstalled/updated with the new token.');">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">Rotate token</button> <button type="submit" class="btn btn-sm btn-ghost">Rotate token</button>
</form> </form>
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0;" <form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0;"
onsubmit="return confirm('Remove this agent registration? Metrics stop until re-registered.');"> onsubmit="return confirm('Remove this agent registration? Metrics stop until re-registered.');">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Remove</button> <button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form> </form>
{% endif %} {% endif %}
@@ -49,7 +49,7 @@
Connects over a one-time bootstrap user + password (not stored). Connects over a one-time bootstrap user + password (not stored).
</p> </p>
<form method="post" action="/plugins/host_agent/provision" <form method="post" action="/plugins/host_agent/provision"
style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;"> style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<input type="hidden" name="inventory_scope" value="{{ scope }}"> <input type="hidden" name="inventory_scope" value="{{ scope }}">
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label>Bootstrap user</label> <label>Bootstrap user</label>
@@ -96,7 +96,7 @@
border-radius:6px;padding:0.7rem 0.9rem;display:flex;align-items:center;gap:0.8rem;flex-wrap:wrap;"> border-radius:6px;padding:0.7rem 0.9rem;display:flex;align-items:center;gap:0.8rem;flex-wrap:wrap;">
<span style="color:var(--yellow);">⚠</span> <span style="color:var(--yellow);">⚠</span>
<span style="font-size:0.84rem;flex:1;min-width:13rem;">No managed SSH key yet — Steward needs one to log into hosts.</span> <span style="font-size:0.84rem;flex:1;min-width:13rem;">No managed SSH key yet — Steward needs one to log into hosts.</span>
<form method="post" action="/settings/ansible/generate-key" style="margin:0;"> <form method="post" action="/settings/ansible/generate-key" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="next" value="/hosts/{{ host.id }}"> <input type="hidden" name="next" value="/hosts/{{ host.id }}">
<button type="submit" class="btn btn-sm">Generate managed key</button> <button type="submit" class="btn btn-sm">Generate managed key</button>
</form> </form>
@@ -115,7 +115,7 @@
</p> </p>
{# Provision: brand-new host (creates steward account + key over a one-time password) #} {# Provision: brand-new host (creates steward account + key over a one-time password) #}
<form method="post" action="/plugins/host_agent/provision" <form method="post" action="/plugins/host_agent/provision"
style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:0.75rem;"> style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:0.75rem;">{{ csrf_field() }}
<input type="hidden" name="inventory_scope" value="{{ scope }}"> <input type="hidden" name="inventory_scope" value="{{ scope }}">
<div style="font-size:0.78rem;color:var(--text-muted);width:100%;">Provision (first contact — fresh host):</div> <div style="font-size:0.78rem;color:var(--text-muted);width:100%;">Provision (first contact — fresh host):</div>
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
@@ -129,7 +129,7 @@
<button type="submit" class="btn btn-sm">Provision</button> <button type="submit" class="btn btn-sm">Provision</button>
</form> </form>
{# Install: host already has the steward account (managed key works) #} {# Install: host already has the steward account (managed key works) #}
<form method="post" action="/plugins/host_agent/deploy" style="display:flex;gap:0.6rem;align-items:center;"> <form method="post" action="/plugins/host_agent/deploy" style="display:flex;gap:0.6rem;align-items:center;">{{ csrf_field() }}
<input type="hidden" name="inventory_scope" value="{{ scope }}"> <input type="hidden" name="inventory_scope" value="{{ scope }}">
<span style="font-size:0.78rem;color:var(--text-muted);">Already provisioned?</span> <span style="font-size:0.78rem;color:var(--text-muted);">Already provisioned?</span>
<button type="submit" class="btn btn-sm btn-ghost">Install agent (managed key)</button> <button type="submit" class="btn btn-sm btn-ghost">Install agent (managed key)</button>
@@ -138,7 +138,7 @@
{% if reg and session.user_role == 'admin' %} {% if reg and session.user_role == 'admin' %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0.75rem 0 0;" <form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0.75rem 0 0;"
onsubmit="return confirm('Clear this pending registration? Its token is discarded.');"> onsubmit="return confirm('Clear this pending registration? Its token is discarded.');">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">Clear pending registration</button> <button type="submit" class="btn btn-sm btn-ghost">Clear pending registration</button>
</form> </form>
{% endif %} {% endif %}
@@ -23,7 +23,7 @@
<div class="card"> <div class="card">
<form method="post" action="/plugins/host_agent/fleet/add-host" <form method="post" action="/plugins/host_agent/fleet/add-host"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;"> style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0;"> <div class="form-group" style="margin-bottom:0;">
<label>Host name</label> <label>Host name</label>
<input type="text" name="name" required> <input type="text" name="name" required>
@@ -63,7 +63,7 @@
<span style="font-size:0.84rem;flex:1;min-width:14rem;"> <span style="font-size:0.84rem;flex:1;min-width:14rem;">
No managed SSH key yet — Steward needs one to log into hosts as <code>steward</code>. No managed SSH key yet — Steward needs one to log into hosts as <code>steward</code>.
</span> </span>
<form method="post" action="/settings/ansible/generate-key" style="margin:0;"> <form method="post" action="/settings/ansible/generate-key" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="next" value="/plugins/host_agent/fleet/"> <input type="hidden" name="next" value="/plugins/host_agent/fleet/">
<button type="submit" class="btn btn-sm">Generate managed key</button> <button type="submit" class="btn btn-sm">Generate managed key</button>
</form> </form>
@@ -85,7 +85,7 @@
agent. Run this once per host. agent. Run this once per host.
</p> </p>
<form method="post" action="/plugins/host_agent/provision" <form method="post" action="/plugins/host_agent/provision"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;"> style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0;">{{ scope_select }}</div> <div class="form-group" style="margin-bottom:0;">{{ scope_select }}</div>
<div class="form-group" style="margin-bottom:0;"> <div class="form-group" style="margin-bottom:0;">
<label>Bootstrap user</label> <label>Bootstrap user</label>
@@ -111,7 +111,7 @@
mints a fresh token, and installs the agent. No <code>curl | sh</code> needed. mints a fresh token, and installs the agent. No <code>curl | sh</code> needed.
</p> </p>
<form method="post" action="/plugins/host_agent/deploy" <form method="post" action="/plugins/host_agent/deploy"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;"> style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0;">{{ scope_select }}</div> <div class="form-group" style="margin-bottom:0;">{{ scope_select }}</div>
<div class="form-group" style="margin-bottom:0;"> <div class="form-group" style="margin-bottom:0;">
<label>Report interval (s)</label> <label>Report interval (s)</label>
@@ -129,7 +129,7 @@
so identity is preserved. so identity is preserved.
</p> </p>
<form method="post" action="/plugins/host_agent/update" <form method="post" action="/plugins/host_agent/update"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;"> style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0;">{{ scope_select }}</div> <div class="form-group" style="margin-bottom:0;">{{ scope_select }}</div>
<button type="submit" class="btn">Update</button> <button type="submit" class="btn">Update</button>
</form> </form>
@@ -156,12 +156,12 @@
<td class="td-actions"> <td class="td-actions">
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token" <form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token"
style="display:inline;" style="display:inline;"
onsubmit="return confirm('Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.');"> onsubmit="return confirm('Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.');">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">Rotate token</button> <button type="submit" class="btn btn-ghost btn-sm">Rotate token</button>
</form> </form>
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete" <form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
style="display:inline;" style="display:inline;"
onsubmit="return confirm('Delete this host registration? The agent will be unable to report metrics until re-registered.');"> onsubmit="return confirm('Delete this host registration? The agent will be unable to report metrics until re-registered.');">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button> <button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form> </form>
</td> </td>
+13
View File
@@ -37,6 +37,9 @@ def create_app(
PLUGIN_DIRS=bootstrap["plugin_dirs"], PLUGIN_DIRS=bootstrap["plugin_dirs"],
PLUGIN_INSTALL_DIR=bootstrap["plugin_install_dir"], PLUGIN_INSTALL_DIR=bootstrap["plugin_install_dir"],
TESTING=testing, 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 ────────────────────────────────────────────────────── # ── 2. Init DB engine ──────────────────────────────────────────────────────
@@ -94,6 +97,7 @@ def create_app(
SESSION_LIFETIME_HOURS=_settings.get("session.lifetime_hours", 8), SESSION_LIFETIME_HOURS=_settings.get("session.lifetime_hours", 8),
DATA_RETENTION_DAYS=_settings.get("data.retention_days", 90), DATA_RETENTION_DAYS=_settings.get("data.retention_days", 90),
MONITORS_POLL_INTERVAL=_settings.get("monitors.poll_interval_seconds", 60), MONITORS_POLL_INTERVAL=_settings.get("monitors.poll_interval_seconds", 60),
PUBLIC_BASE_URL=_settings.get("general.public_base_url", ""),
SMTP=to_smtp_cfg(_settings), SMTP=to_smtp_cfg(_settings),
WEBHOOK=to_webhook_cfg(_settings), WEBHOOK=to_webhook_cfg(_settings),
ANSIBLE=to_ansible_cfg(_settings), ANSIBLE=to_ansible_cfg(_settings),
@@ -108,6 +112,7 @@ def create_app(
SESSION_LIFETIME_HOURS=8, SESSION_LIFETIME_HOURS=8,
DATA_RETENTION_DAYS=90, DATA_RETENTION_DAYS=90,
MONITORS_POLL_INTERVAL=60, MONITORS_POLL_INTERVAL=60,
PUBLIC_BASE_URL="",
SMTP={}, SMTP={},
WEBHOOK={}, WEBHOOK={},
ANSIBLE={}, ANSIBLE={},
@@ -125,6 +130,14 @@ def create_app(
lambda value, kind: _ts_for(value, kind, app.config.get("THRESHOLDS", {})) 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 ─────────────────────────────────────────────────── # ── 5. Plugin migrations ───────────────────────────────────────────────────
# Step 3 already applied all plugin migrations via discover_all_plugin_migration_dirs, # 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 # so this is a no-op on normal startup. We still run it with all discovered dirs so
+63
View File
@@ -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 <body> 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'<input type="hidden" name="{FORM_FIELD}" value="{escape(csrf_token())}">')
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")
+3 -1
View File
@@ -82,7 +82,9 @@ def require_role(minimum_role: UserRole):
def login_user(user) -> None: 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_id"] = user.id
session["user_role"] = user.role.value session["user_role"] = user.role.value
session["username"] = user.username session["username"] = user.username
+2 -2
View File
@@ -59,7 +59,7 @@
<td class="td-actions"> <td class="td-actions">
{% if row.state == 'active' %} {% if row.state == 'active' %}
<form method="post" action="/ai-access/tokens/{{ t.id }}/revoke" style="margin:0;" <form method="post" action="/ai-access/tokens/{{ t.id }}/revoke" style="margin:0;"
onsubmit='return confirm({{ ("Revoke “" ~ t.name ~ "”? Any client using it stops working immediately.")|tojson }})'> onsubmit='return confirm({{ ("Revoke “" ~ t.name ~ "”? Any client using it stops working immediately.")|tojson }})'>{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Revoke</button> <button type="submit" class="btn btn-danger btn-sm">Revoke</button>
</form> </form>
{% endif %} {% endif %}
@@ -79,7 +79,7 @@
<div> <div>
<div class="section-title" style="margin-bottom:0.75rem;">Create a token</div> <div class="section-title" style="margin-bottom:0.75rem;">Create a token</div>
<div class="card" style="margin-bottom:0;"> <div class="card" style="margin-bottom:0;">
<form method="post" action="/ai-access/tokens"> <form method="post" action="/ai-access/tokens">{{ csrf_field() }}
<div class="form-group"> <div class="form-group">
<label for="tok-name">Name</label> <label for="tok-name">Name</label>
<input type="text" id="tok-name" name="name" required maxlength="128" <input type="text" id="tok-name" name="name" required maxlength="128"
+3 -3
View File
@@ -33,7 +33,7 @@
<td style="color:var(--text-muted);">{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}</td> <td style="color:var(--text-muted);">{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}</td>
<td class="td-actions"> <td class="td-actions">
{% if state.state.value == 'firing' %} {% if state.state.value == 'firing' %}
<form method="post" action="/alerts/{{ rule.id }}/acknowledge" style="display:inline;"> <form method="post" action="/alerts/{{ rule.id }}/acknowledge" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-warn">Acknowledge</button> <button type="submit" class="btn btn-sm btn-warn">Acknowledge</button>
</form> </form>
{% endif %} {% endif %}
@@ -83,12 +83,12 @@
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }}</td> <td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }}</td>
<td class="td-actions"> <td class="td-actions">
<a href="/alerts/rules/{{ rule.id }}/edit" class="btn btn-sm btn-ghost">Edit</a> <a href="/alerts/rules/{{ rule.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;"> <form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost"> <button type="submit" class="btn btn-sm btn-ghost">
{% if rule.enabled %}Disable{% else %}Enable{% endif %} {% if rule.enabled %}Disable{% else %}Enable{% endif %}
</button> </button>
</form> </form>
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;"> <form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button> onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
</form> </form>
+1 -1
View File
@@ -59,7 +59,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="td-actions"> <td class="td-actions">
<form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;"> <form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete window &quot;{{ w.name }}&quot;?')">Delete</button> onclick="return confirm('Delete window &quot;{{ w.name }}&quot;?')">Delete</button>
</form> </form>
@@ -6,7 +6,7 @@
<div style="max-width:560px;margin:2rem auto;"> <div style="max-width:560px;margin:2rem auto;">
<h1 class="page-title">New Maintenance Window</h1> <h1 class="page-title">New Maintenance Window</h1>
<div class="card"> <div class="card">
<form method="post" action="/alerts/maintenance"> <form method="post" action="/alerts/maintenance">{{ csrf_field() }}
<div class="form-group"> <div class="form-group">
<label>Window Name</label> <label>Window Name</label>
+1 -1
View File
@@ -6,7 +6,7 @@
<div style="max-width:600px;margin:2rem auto;"> <div style="max-width:600px;margin:2rem auto;">
<h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1> <h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1>
<div class="card"> <div class="card">
<form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}"> <form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">{{ csrf_field() }}
<div class="form-group"> <div class="form-group">
<label>Rule Name</label> <label>Rule Name</label>
+1 -1
View File
@@ -28,7 +28,7 @@
{% if view_editable and session.user_role == 'admin' %} {% if view_editable and session.user_role == 'admin' %}
<a href="/ansible/playbooks/edit/{{ view_source }}/{{ view_path }}" class="btn btn-sm">Edit</a> <a href="/ansible/playbooks/edit/{{ view_source }}/{{ view_path }}" class="btn btn-sm">Edit</a>
<form method="post" action="/ansible/playbooks/delete" style="display:inline;" <form method="post" action="/ansible/playbooks/delete" style="display:inline;"
onsubmit="return confirm('Delete {{ view_path }}?');"> onsubmit="return confirm('Delete {{ view_path }}?');">{{ csrf_field() }}
<input type="hidden" name="source_name" value="{{ view_source }}"> <input type="hidden" name="source_name" value="{{ view_source }}">
<input type="hidden" name="playbook_path" value="{{ view_path }}"> <input type="hidden" name="playbook_path" value="{{ view_path }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button> <button type="submit" class="btn btn-sm btn-danger">Delete</button>
@@ -7,7 +7,7 @@
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1> <h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
</div> </div>
<form method="post" action="/ansible/inventory/groups/{{ group.id }}"> <form method="post" action="/ansible/inventory/groups/{{ group.id }}">{{ csrf_field() }}
<div class="card" style="margin-bottom:1.5rem;"> <div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Settings</h3> <h3 class="section-title">Settings</h3>
<div class="form-group"> <div class="form-group">
@@ -12,7 +12,7 @@
<div class="card" style="margin-bottom:1.5rem;"> <div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">New Group</h3> <h3 class="section-title">New Group</h3>
<form method="post" action="/ansible/inventory/groups" style="display:flex;gap:0.75rem;align-items:flex-end;"> <form method="post" action="/ansible/inventory/groups" style="display:flex;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
<div class="form-group" style="flex:1;margin:0;"> <div class="form-group" style="flex:1;margin:0;">
<label>Name</label> <label>Name</label>
<input type="text" name="name" placeholder="webservers" required> <input type="text" name="name" placeholder="webservers" required>
@@ -38,7 +38,7 @@
<td style="color:var(--text-muted);font-size:0.9rem;">{{ grp.targets | length }}</td> <td style="color:var(--text-muted);font-size:0.9rem;">{{ grp.targets | length }}</td>
<td class="td-actions"> <td class="td-actions">
<a href="/ansible/inventory/groups/{{ grp.id }}" class="btn btn-sm btn-ghost">Edit</a> <a href="/ansible/inventory/groups/{{ grp.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;"> <form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete group {{ grp.name }}?')">×</button> onclick="return confirm('Delete group {{ grp.name }}?')">×</button>
</form> </form>
@@ -7,7 +7,7 @@
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1> <h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
</div> </div>
<form method="post" action="/ansible/inventory/targets/{{ target.id }}"> <form method="post" action="/ansible/inventory/targets/{{ target.id }}">{{ csrf_field() }}
<div class="card" style="margin-bottom:1.5rem;"> <div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Connection</h3> <h3 class="section-title">Connection</h3>
<div class="form-group"> <div class="form-group">
@@ -12,7 +12,7 @@
<div class="card" style="margin-bottom:1.5rem;"> <div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">New Target</h3> <h3 class="section-title">New Target</h3>
<form method="post" action="/ansible/inventory/targets" style="display:flex;gap:0.75rem;align-items:flex-end;"> <form method="post" action="/ansible/inventory/targets" style="display:flex;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
<div class="form-group" style="flex:1;margin:0;"> <div class="form-group" style="flex:1;margin:0;">
<label>Name <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(used as Ansible hostname)</span></label> <label>Name <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(used as Ansible hostname)</span></label>
<input type="text" name="name" placeholder="webserver-01" required> <input type="text" name="name" placeholder="webserver-01" required>
@@ -47,7 +47,7 @@
</td> </td>
<td class="td-actions"> <td class="td-actions">
<a href="/ansible/inventory/targets/{{ tgt.id }}" class="btn btn-sm btn-ghost">Edit</a> <a href="/ansible/inventory/targets/{{ tgt.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/targets/{{ tgt.id }}/delete" style="display:inline;"> <form method="post" action="/ansible/inventory/targets/{{ tgt.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete target {{ tgt.name }}?')">×</button> onclick="return confirm('Delete target {{ tgt.name }}?')">×</button>
</form> </form>
@@ -15,7 +15,7 @@
automatically; you can also configure a local-dir source under <a href="/settings/ansible/">Settings → Ansible</a>. automatically; you can also configure a local-dir source under <a href="/settings/ansible/">Settings → Ansible</a>.
</div> </div>
{% else %} {% else %}
<form method="post" action="/ansible/playbooks/save"> <form method="post" action="/ansible/playbooks/save">{{ csrf_field() }}
<input type="hidden" name="editing" value="{{ '1' if editing else '0' }}"> <input type="hidden" name="editing" value="{{ '1' if editing else '0' }}">
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;"> <div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;">
<div class="form-group" style="margin-bottom:0;"> <div class="form-group" style="margin-bottom:0;">
@@ -54,7 +54,7 @@
{% if editing %} {% if editing %}
<form method="post" action="/ansible/playbooks/delete" style="margin-top:1rem;" <form method="post" action="/ansible/playbooks/delete" style="margin-top:1rem;"
onsubmit="return confirm('Delete {{ playbook_path }}?');"> onsubmit="return confirm('Delete {{ playbook_path }}?');">{{ csrf_field() }}
<input type="hidden" name="source_name" value="{{ source_name }}"> <input type="hidden" name="source_name" value="{{ source_name }}">
<input type="hidden" name="playbook_path" value="{{ playbook_path }}"> <input type="hidden" name="playbook_path" value="{{ playbook_path }}">
<button type="submit" class="btn btn-danger btn-sm">Delete playbook</button> <button type="submit" class="btn btn-danger btn-sm">Delete playbook</button>
+1 -1
View File
@@ -7,7 +7,7 @@
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1> <h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
<div style="margin-left:auto;display:flex;gap:0.5rem;"> <div style="margin-left:auto;display:flex;gap:0.5rem;">
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %} {% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" onsubmit="return confirm('Cancel this run?');"> <form method="post" action="/ansible/runs/{{ run.id }}/cancel" onsubmit="return confirm('Cancel this run?');">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button> <button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
</form> </form>
{% endif %} {% endif %}
+4 -4
View File
@@ -54,15 +54,15 @@
{% else %}due now{% endif %} {% else %}due now{% endif %}
</td> </td>
<td class="td-actions" style="white-space:nowrap;"> <td class="td-actions" style="white-space:nowrap;">
<form method="post" action="/ansible/schedules/{{ s.id }}/run-now" style="display:inline;"> <form method="post" action="/ansible/schedules/{{ s.id }}/run-now" style="display:inline;">{{ csrf_field() }}
<button class="btn btn-sm btn-ghost" type="submit">Run now</button> <button class="btn btn-sm btn-ghost" type="submit">Run now</button>
</form> </form>
<form method="post" action="/ansible/schedules/{{ s.id }}/toggle" style="display:inline;"> <form method="post" action="/ansible/schedules/{{ s.id }}/toggle" style="display:inline;">{{ csrf_field() }}
<button class="btn btn-sm btn-ghost" type="submit">{{ "Pause" if s.enabled else "Resume" }}</button> <button class="btn btn-sm btn-ghost" type="submit">{{ "Pause" if s.enabled else "Resume" }}</button>
</form> </form>
<a href="?edit={{ s.id }}#form" class="btn btn-sm btn-ghost">Edit</a> <a href="?edit={{ s.id }}#form" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/schedules/{{ s.id }}/delete" style="display:inline;" <form method="post" action="/ansible/schedules/{{ s.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete schedule {{ s.name }}?');"> onsubmit="return confirm('Delete schedule {{ s.name }}?');">{{ csrf_field() }}
<button class="btn btn-sm btn-danger" type="submit">Delete</button> <button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form> </form>
</td> </td>
@@ -79,7 +79,7 @@
{% set declared_names = (edit_variables or []) | map(attribute='name') | list %} {% set declared_names = (edit_variables or []) | map(attribute='name') | list %}
<div class="card" id="form"> <div class="card" id="form">
<h2 class="section-title" style="margin-bottom:1rem;">{{ "Edit schedule" if editing else "New schedule" }}</h2> <h2 class="section-title" style="margin-bottom:1rem;">{{ "Edit schedule" if editing else "New schedule" }}</h2>
<form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}"> <form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;">
<div class="form-group" style="margin-bottom:0;"> <div class="form-group" style="margin-bottom:0;">
<label>Name</label> <label>Name</label>
+1 -1
View File
@@ -20,7 +20,7 @@
<div style="flex:1;height:1px;background:var(--border-mid);"></div> <div style="flex:1;height:1px;background:var(--border-mid);"></div>
</div> </div>
{% endif %} {% endif %}
<form method="post" action="/login"> <form method="post" action="/login">{{ csrf_field() }}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %} {% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<div class="form-group"> <div class="form-group">
<input type="text" name="username" placeholder="Username" autofocus required> <input type="text" name="username" placeholder="Username" autofocus required>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="card"> <div class="card">
<h2 class="page-title" style="margin-bottom:0.5rem;">First-Run Setup</h2> <h2 class="page-title" style="margin-bottom:0.5rem;">First-Run Setup</h2>
<p style="margin-bottom:1.5rem;color:var(--text-muted);font-size:0.9rem;">Create the initial admin account.</p> <p style="margin-bottom:1.5rem;color:var(--text-muted);font-size:0.9rem;">Create the initial admin account.</p>
<form method="post" action="/setup"> <form method="post" action="/setup">{{ csrf_field() }}
<div class="form-group"> <div class="form-group">
<label>Username</label> <label>Username</label>
<input type="text" name="username" autofocus required> <input type="text" name="username" autofocus required>
+8
View File
@@ -3,6 +3,14 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// Every HTMX request carries this session's forged-request token; fetch()
// callers read the same meta tag.
document.addEventListener('htmx:configRequest', function (ev) {
ev.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
});
</script>
<title>{% block title %}Steward{% endblock %}</title> <title>{% block title %}Steward{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script> <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
@@ -27,7 +27,7 @@
</summary> </summary>
<form action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}" method="post" <form action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}" method="post"
class="add-widget-form" class="add-widget-form"
style="padding:0.75rem 0.85rem;padding-top:0;border-top:1px solid var(--border);"> style="padding:0.75rem 0.85rem;padding-top:0;border-top:1px solid var(--border);">{{ csrf_field() }}
<div style="display:grid;gap:0.5rem;padding-top:0.75rem;"> <div style="display:grid;gap:0.5rem;padding-top:0.75rem;">
<div> <div>
<label style="font-size:0.74rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;"> <label style="font-size:0.74rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
+5 -3
View File
@@ -73,13 +73,14 @@
}, gridEl); }, gridEl);
if (!canEdit) return; // viewers get the static grid only if (!canEdit) return; // viewers get the static grid only
var csrf = document.querySelector('meta[name="csrf-token"]').content;
function saveLayout() { function saveLayout() {
var items = grid.save(false).map(function (n) { var items = grid.save(false).map(function (n) {
return { id: parseInt(n.id, 10), x: n.x, y: n.y, w: n.w, h: n.h }; return { id: parseInt(n.id, 10), x: n.x, y: n.y, w: n.w, h: n.h };
}); });
fetch('/d/' + dashId + '/edit/layout', { fetch('/d/' + dashId + '/edit/layout', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ items: items }), body: JSON.stringify({ items: items }),
}); });
} }
@@ -101,7 +102,7 @@
document.querySelectorAll('.add-widget-form').forEach(function (form) { document.querySelectorAll('.add-widget-form').forEach(function (form) {
form.addEventListener('submit', function (ev) { form.addEventListener('submit', function (ev) {
ev.preventDefault(); ev.preventDefault();
fetch(form.action, { method: 'POST', body: new FormData(form) }) fetch(form.action, { method: 'POST', body: new FormData(form), headers: { 'X-CSRF-Token': csrf } })
.then(function (r) { return r.status === 204 ? '' : r.text(); }) .then(function (r) { return r.status === 204 ? '' : r.text(); })
.then(function (html) { .then(function (html) {
if (!html) return; if (!html) return;
@@ -126,7 +127,8 @@
if (!rm) return; if (!rm) return;
ev.preventDefault(); ev.preventDefault();
var item = rm.closest('.grid-stack-item'); var item = rm.closest('.grid-stack-item');
fetch('/d/' + dashId + '/edit/remove/' + rm.dataset.remove, { method: 'POST' }) fetch('/d/' + dashId + '/edit/remove/' + rm.dataset.remove,
{ method: 'POST', headers: { 'X-CSRF-Token': csrf } })
.then(function () { if (item) grid.removeWidget(item); }); .then(function () { if (item) grid.removeWidget(item); });
}); });
+6 -6
View File
@@ -37,7 +37,7 @@
<details style="position:relative;"> <details style="position:relative;">
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary> <summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);"> <div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;"> <form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">{{ csrf_field() }}
<input type="text" name="name" value="{{ dash.name }}" <input type="text" name="name" value="{{ dash.name }}"
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required> style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
<button type="submit" class="btn btn-sm">Save</button> <button type="submit" class="btn btn-sm">Save</button>
@@ -45,19 +45,19 @@
</div> </div>
</details> </details>
<form method="post" action="/dashboards/{{ dash.id }}/toggle-share" style="margin:0;" title="{{ 'Make private' if dash.is_shared else 'Share with all users' }}"> <form method="post" action="/dashboards/{{ dash.id }}/toggle-share" style="margin:0;" title="{{ 'Make private' if dash.is_shared else 'Share with all users' }}">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">{{ '🔒' if dash.is_shared else '🔗' }}</button> <button type="submit" class="btn btn-ghost btn-sm">{{ '🔒' if dash.is_shared else '🔗' }}</button>
</form> </form>
{% if not dash.is_default %} {% if not dash.is_default %}
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default"> <form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">★</button> <button type="submit" class="btn btn-ghost btn-sm">★</button>
</form> </form>
{% endif %} {% endif %}
{% if my_dashboards | length > 1 %} {% if my_dashboards | length > 1 %}
<form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;" <form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;"
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')"> onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button> <button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form> </form>
{% endif %} {% endif %}
@@ -94,7 +94,7 @@
</div> </div>
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;"> <div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
<a href="/d/{{ dash.id }}" class="btn btn-ghost btn-sm">View</a> <a href="/d/{{ dash.id }}" class="btn btn-ghost btn-sm">View</a>
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default"> <form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">★</button> <button type="submit" class="btn btn-ghost btn-sm">★</button>
</form> </form>
{# Admins can also edit system dashboards #} {# Admins can also edit system dashboards #}
@@ -115,7 +115,7 @@
<div> <div>
<div class="section-title" style="margin-bottom:0.75rem;">New Dashboard</div> <div class="section-title" style="margin-bottom:0.75rem;">New Dashboard</div>
<div class="card" style="margin-bottom:0;"> <div class="card" style="margin-bottom:0;">
<form method="post" action="/dashboards/create"> <form method="post" action="/dashboards/create">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0.75rem;"> <div class="form-group" style="margin-bottom:0.75rem;">
<label>Name</label> <label>Name</label>
<input type="text" name="name" placeholder="e.g. Network Overview" required> <input type="text" name="name" placeholder="e.g. Network Overview" required>
+2 -2
View File
@@ -32,7 +32,7 @@
</div> </div>
</div> </div>
<form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}" <form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}"
style="margin:0;" onsubmit="return confirm('Revoke this share link?')"> style="margin:0;" onsubmit="return confirm('Revoke this share link?')">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Revoke</button> <button type="submit" class="btn btn-danger btn-sm">Revoke</button>
</form> </form>
</div> </div>
@@ -50,7 +50,7 @@
<div> <div>
<div class="section-title" style="margin-bottom:0.75rem;">Create Share Link</div> <div class="section-title" style="margin-bottom:0.75rem;">Create Share Link</div>
<div class="card" style="margin-bottom:0;"> <div class="card" style="margin-bottom:0;">
<form method="post" action="/d/{{ dashboard.id }}/share/create"> <form method="post" action="/d/{{ dashboard.id }}/share/create">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0.75rem;"> <div class="form-group" style="margin-bottom:0.75rem;">
<label>Label <span style="color:var(--text-muted);font-size:0.78rem;">(optional)</span></label> <label>Label <span style="color:var(--text-muted);font-size:0.78rem;">(optional)</span></label>
<input type="text" name="label" placeholder="e.g. Public display screen"> <input type="text" name="label" placeholder="e.g. Public display screen">
+7 -7
View File
@@ -16,7 +16,7 @@
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm">Edit</a> <a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
{% if session.user_role == 'admin' %} {% if session.user_role == 'admin' %}
<form method="post" action="/hosts/{{ host.id }}/delete" style="margin:0;" <form method="post" action="/hosts/{{ host.id }}/delete" style="margin:0;"
onsubmit="return confirm('Delete host {{ host.name }}? Monitors and history are removed.');"> onsubmit="return confirm('Delete host {{ host.name }}? Monitors and history are removed.');">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button> <button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form> </form>
{% endif %} {% endif %}
@@ -78,7 +78,7 @@
<div style="display:flex;gap:0.35rem;flex-shrink:0;"> <div style="display:flex;gap:0.35rem;flex-shrink:0;">
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm btn-ghost" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a> <a href="/monitors/{{ m.id }}/edit" class="btn btn-sm btn-ghost" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;" <form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete monitor {{ m.name }}?');"> onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button> <button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
</form> </form>
</div> </div>
@@ -94,7 +94,7 @@
{% if session.user_role in ['operator', 'admin'] %} {% if session.user_role in ['operator', 'admin'] %}
<details style="margin-top:0.75rem;"> <details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">+ Add monitor for this host</summary> <summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">+ Add monitor for this host</summary>
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;margin-top:0.75rem;"> <form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;margin-top:0.75rem;">{{ csrf_field() }}
<input type="hidden" name="host_id" value="{{ host.id }}"> <input type="hidden" name="host_id" value="{{ host.id }}">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.6rem;align-items:end;"> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.6rem;align-items:end;">
<div> <div>
@@ -148,7 +148,7 @@
{% endif %} {% endif %}
</div> </div>
<a href="/ansible/inventory/targets/{{ linked_target.id }}" class="btn btn-ghost btn-sm">Edit target</a> <a href="/ansible/inventory/targets/{{ linked_target.id }}" class="btn btn-ghost btn-sm">Edit target</a>
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;"> <form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="action" value="unlink"> <input type="hidden" name="action" value="unlink">
<button type="submit" class="btn btn-ghost btn-sm">Unlink</button> <button type="submit" class="btn btn-ghost btn-sm">Unlink</button>
</form> </form>
@@ -157,7 +157,7 @@
{% if ansible_sources %} {% if ansible_sources %}
<details> <details>
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">Run a playbook against this host</summary> <summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">Run a playbook against this host</summary>
<form method="post" action="/hosts/{{ host.id }}/run-playbook" style="margin-top:0.75rem;"> <form method="post" action="/hosts/{{ host.id }}/run-playbook" style="margin-top:0.75rem;">{{ csrf_field() }}
<div class="form-group"> <div class="form-group">
<label>Source</label> <label>Source</label>
<select name="source_name" required <select name="source_name" required
@@ -202,7 +202,7 @@
</p> </p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;"> <div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
{% if linkable_targets %} {% if linkable_targets %}
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:flex;gap:0.5rem;align-items:center;"> <form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:flex;gap:0.5rem;align-items:center;">{{ csrf_field() }}
<input type="hidden" name="action" value="link"> <input type="hidden" name="action" value="link">
<select name="target_id"> <select name="target_id">
{% for tgt in linkable_targets %}<option value="{{ tgt.id }}">{{ tgt.name }} ({{ tgt.address }})</option>{% endfor %} {% for tgt in linkable_targets %}<option value="{{ tgt.id }}">{{ tgt.name }} ({{ tgt.address }})</option>{% endfor %}
@@ -210,7 +210,7 @@
<button type="submit" class="btn btn-sm">Link</button> <button type="submit" class="btn btn-sm">Link</button>
</form> </form>
{% endif %} {% endif %}
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;"> <form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="action" value="create"> <input type="hidden" name="action" value="create">
<button type="submit" class="btn btn-sm btn-ghost">Create target from this host</button> <button type="submit" class="btn btn-sm btn-ghost">Create target from this host</button>
</form> </form>
+1 -1
View File
@@ -6,7 +6,7 @@
<div style="max-width:560px;margin:2rem auto;"> <div style="max-width:560px;margin:2rem auto;">
<h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1> <h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1>
<div class="card"> <div class="card">
<form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}"> <form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}">{{ csrf_field() }}
<div class="form-group"> <div class="form-group">
<label>Display Name</label> <label>Display Name</label>
<input type="text" name="name" value="{{ host.name if host else '' }}" required autofocus> <input type="text" name="name" value="{{ host.name if host else '' }}" required autofocus>
+1 -1
View File
@@ -78,7 +78,7 @@
</td> </td>
<td class="td-actions"> <td class="td-actions">
<a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a> <a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
<form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;"> <form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete {{ host.name }}?')">Delete</button> onclick="return confirm('Delete {{ host.name }}?')">Delete</button>
</form> </form>
+1 -1
View File
@@ -12,7 +12,7 @@
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %} {% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
<div class="card" style="max-width:760px;"> <div class="card" style="max-width:760px;">
<form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;"> <form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;"> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div> <div>
<label style="{{ lbl }}">Type</label> <label style="{{ lbl }}">Type</label>
+1 -1
View File
@@ -16,7 +16,7 @@
{# ── Add monitor ──────────────────────────────────────────────────────────── #} {# ── Add monitor ──────────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;"> <div class="card" style="margin-bottom:1.25rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div> <div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;"> <form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;"> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div> <div>
<label style="{{ lbl }}">Type</label> <label style="{{ lbl }}">Type</label>
+2 -2
View File
@@ -54,12 +54,12 @@
{% if session.user_role in ['operator', 'admin'] %} {% if session.user_role in ['operator', 'admin'] %}
<div style="display:flex;gap:0.35rem;flex-shrink:0;"> <div style="display:flex;gap:0.35rem;flex-shrink:0;">
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a> <a href="/monitors/{{ m.id }}/edit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
<form method="post" action="/monitors/{{ m.id }}/toggle" style="display:inline;"> <form method="post" action="/monitors/{{ m.id }}/toggle" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;"> <button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">
{{ "Pause" if m.enabled else "Resume" }}</button> {{ "Pause" if m.enabled else "Resume" }}</button>
</form> </form>
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;" <form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete monitor {{ m.name }}?');"> onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button> <button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
</form> </form>
</div> </div>
@@ -63,7 +63,7 @@
hx-post="/settings/plugins/install/{{ plugin.name }}" hx-post="/settings/plugins/install/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}" hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML" hx-swap="outerHTML"
style="margin:0;"> style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="download_url" value="{{ plugin.download_url }}"> <input type="hidden" name="download_url" value="{{ plugin.download_url }}">
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}"> <input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
<button type="submit" class="btn btn-warn btn-sm" style="font-size:0.78rem;">Update</button> <button type="submit" class="btn btn-warn btn-sm" style="font-size:0.78rem;">Update</button>
@@ -79,7 +79,7 @@
hx-post="/settings/plugins/reload/{{ plugin.name }}" hx-post="/settings/plugins/reload/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}" hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML" hx-swap="outerHTML"
style="margin:0;"> style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button> <button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button>
</form> </form>
@@ -90,7 +90,7 @@
hx-post="/settings/plugins/install/{{ plugin.name }}" hx-post="/settings/plugins/install/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}" hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML" hx-swap="outerHTML"
style="margin:0;"> style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="download_url" value="{{ plugin.download_url }}"> <input type="hidden" name="download_url" value="{{ plugin.download_url }}">
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}"> <input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button> <button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button>
@@ -10,7 +10,7 @@
hx-post="/settings/plugins/restart/" hx-post="/settings/plugins/restart/"
hx-target="#restart-banner" hx-target="#restart-banner"
hx-swap="outerHTML" hx-swap="outerHTML"
style="display:inline;margin:0;"> style="display:inline;margin:0;">{{ csrf_field() }}
<button type="submit" <button type="submit"
style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;"> style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;">
Restart now Restart now
+3 -3
View File
@@ -39,11 +39,11 @@
<span style="color:var(--text-muted);font-size:0.78rem;">Click to select — add to a host's <code>authorized_keys</code> to authorize Steward manually.</span> <span style="color:var(--text-muted);font-size:0.78rem;">Click to select — add to a host's <code>authorized_keys</code> to authorize Steward manually.</span>
</div> </div>
<form method="post" action="/settings/ansible/generate-key" <form method="post" action="/settings/ansible/generate-key"
onsubmit="return confirm('Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.');"> onsubmit="return confirm('Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.');">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">Regenerate key</button> <button type="submit" class="btn btn-ghost btn-sm">Regenerate key</button>
</form> </form>
{% else %} {% else %}
<form method="post" action="/settings/ansible/generate-key"> <form method="post" action="/settings/ansible/generate-key">{{ csrf_field() }}
<button type="submit" class="btn">Generate managed key</button> <button type="submit" class="btn">Generate managed key</button>
<span style="color:var(--text-muted);font-size:0.78rem;margin-left:0.5rem;">ed25519 — created server-side, nothing to paste.</span> <span style="color:var(--text-muted);font-size:0.78rem;margin-left:0.5rem;">ed25519 — created server-side, nothing to paste.</span>
</form> </form>
@@ -56,7 +56,7 @@
Used by every playbook run (manual and alert-triggered). Stored on the server and 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. never shown again after saving — leave a field blank to keep the current value.
</p> </p>
<form method="post" action="/settings/ansible/credentials"> <form method="post" action="/settings/ansible/credentials">{{ csrf_field() }}
<div class="form-group"> <div class="form-group">
<label>SSH private key {{ (cfg_badge if ssh_key_set else not_set_badge) | safe }}</label> <label>SSH private key {{ (cfg_badge if ssh_key_set else not_set_badge) | safe }}</label>
<textarea name="ssh_private_key" rows="4" <textarea name="ssh_private_key" rows="4"
+1 -1
View File
@@ -7,7 +7,7 @@
{% set active_tab = "auth" %} {% set active_tab = "auth" %}
{% include "settings/_tabs.html" %} {% include "settings/_tabs.html" %}
<form method="post" action="/settings/auth/"> <form method="post" action="/settings/auth/">{{ csrf_field() }}
<div style="display:grid;gap:1.25rem;max-width:640px;"> <div style="display:grid;gap:1.25rem;max-width:640px;">
{# ── OIDC ─────────────────────────────────────────────────────────────────── #} {# ── OIDC ─────────────────────────────────────────────────────────────────── #}
+1 -1
View File
@@ -5,7 +5,7 @@
{% set active_tab = "general" %} {% set active_tab = "general" %}
{% include "settings/_tabs.html" %} {% include "settings/_tabs.html" %}
<form method="post" action="/settings/general/"> <form method="post" action="/settings/general/">{{ csrf_field() }}
<div class="card" style="max-width:640px;"> <div class="card" style="max-width:640px;">
<h2 class="section-title" style="margin-bottom:1.25rem;">General</h2> <h2 class="section-title" style="margin-bottom:1.25rem;">General</h2>
+1 -1
View File
@@ -3,7 +3,7 @@
{% block title %}Settings — Steward{% endblock %} {% block title %}Settings — Steward{% endblock %}
{% block content %} {% block content %}
<h1 class="page-title">Settings</h1> <h1 class="page-title">Settings</h1>
<form method="post" action="/settings/"> <form method="post" action="/settings/">{{ csrf_field() }}
<div style="display:grid;gap:1.5rem;"> <div style="display:grid;gap:1.5rem;">
{# ── General ──────────────────────────────────────────────────────────── #} {# ── General ──────────────────────────────────────────────────────────── #}
@@ -7,7 +7,7 @@
{% set active_tab = "notifications" %} {% set active_tab = "notifications" %}
{% include "settings/_tabs.html" %} {% include "settings/_tabs.html" %}
<form method="post" action="/settings/notifications/"> <form method="post" action="/settings/notifications/">{{ csrf_field() }}
<div style="display:grid;gap:1.25rem;max-width:640px;"> <div style="display:grid;gap:1.25rem;max-width:640px;">
{# ── SMTP ──────────────────────────────────────────────────────────────── #} {# ── SMTP ──────────────────────────────────────────────────────────────── #}
@@ -41,7 +41,7 @@
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.25rem;max-width:620px;">{{ plugin.description }}</p> <p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.25rem;max-width:620px;">{{ plugin.description }}</p>
{% endif %} {% endif %}
<form method="post" action="/settings/plugins/{{ plugin._dir }}/" style="max-width:720px;"> <form method="post" action="/settings/plugins/{{ plugin._dir }}/" style="max-width:720px;">{{ csrf_field() }}
{# ── Enable toggle ────────────────────────────────────────────────────────── #} {# ── Enable toggle ────────────────────────────────────────────────────────── #}
<div class="card" style="padding:1rem 1.25rem;margin-bottom:1rem;"> <div class="card" style="padding:1rem 1.25rem;margin-bottom:1rem;">
+2 -2
View File
@@ -55,7 +55,7 @@
<form method="post" action="/settings/plugins/restart/" <form method="post" action="/settings/plugins/restart/"
hx-post="/settings/plugins/restart/" hx-target="#restart-banner" hx-swap="outerHTML" hx-post="/settings/plugins/restart/" hx-target="#restart-banner" hx-swap="outerHTML"
onsubmit="return confirm('Restart the app now? This will briefly interrupt all monitoring.')" onsubmit="return confirm('Restart the app now? This will briefly interrupt all monitoring.')"
style="margin:0;"> style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button> <button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button>
</form> </form>
</div> </div>
@@ -135,7 +135,7 @@
{% if orphan.removable %} {% if orphan.removable %}
<form method="post" action="/settings/plugins/orphans/{{ orphan.name }}/remove/" <form method="post" action="/settings/plugins/orphans/{{ orphan.name }}/remove/"
onsubmit="return confirm('Remove stored settings for &quot;{{ orphan.name }}&quot;? Its saved configuration, including any credentials, will be discarded. This cannot be undone.');" onsubmit="return confirm('Remove stored settings for &quot;{{ orphan.name }}&quot;? Its saved configuration, including any credentials, will be discarded. This cannot be undone.');"
style="margin:0;flex-shrink:0;"> style="margin:0;flex-shrink:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.78rem;">Remove</button> <button type="submit" class="btn btn-danger btn-sm" style="font-size:0.78rem;">Remove</button>
</form> </form>
{% endif %} {% endif %}
+2 -2
View File
@@ -20,7 +20,7 @@
<div style="max-width:560px;"> <div style="max-width:560px;">
{# ── Schedule ─────────────────────────────────────────────────────────────── #} {# ── Schedule ─────────────────────────────────────────────────────────────── #}
<form method="post" action="/settings/reports/"> <form method="post" action="/settings/reports/">{{ csrf_field() }}
<div class="card" style="margin-bottom:1.25rem;"> <div class="card" style="margin-bottom:1.25rem;">
<h2 class="section-title" style="margin-bottom:1.25rem;">Weekly Digest</h2> <h2 class="section-title" style="margin-bottom:1.25rem;">Weekly Digest</h2>
@@ -84,7 +84,7 @@
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:1rem;"> <p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:1rem;">
Send a report immediately regardless of schedule. Useful for testing. Send a report immediately regardless of schedule. Useful for testing.
</p> </p>
<form method="post" action="/settings/reports/send-now/"> <form method="post" action="/settings/reports/send-now/">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost">Send report now</button> <button type="submit" class="btn btn-ghost">Send report now</button>
</form> </form>
</div> </div>
+1 -1
View File
@@ -24,7 +24,7 @@
</div> </div>
{% endmacro %} {% endmacro %}
<form method="post" action="/settings/thresholds/"> <form method="post" action="/settings/thresholds/">{{ csrf_field() }}
<div class="card" style="max-width:640px;"> <div class="card" style="max-width:640px;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Monitoring thresholds</h2> <h2 class="section-title" style="margin-bottom:0.5rem;">Monitoring thresholds</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;"> <p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
+5 -3
View File
@@ -132,6 +132,8 @@ def test_routes_create_list_and_revoke(app):
sess["user_id"] = uid sess["user_id"] = uid
sess["user_role"] = "operator" sess["user_role"] = "operator"
sess["username"] = "route-test" sess["username"] = "route-test"
sess["_csrf"] = "test-token"
csrf = {"X-CSRF-Token": "test-token"}
resp = await client.get("/ai-access/") resp = await client.get("/ai-access/")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -143,12 +145,12 @@ def test_routes_create_list_and_revoke(app):
assert 'value="http://localhost/mcp"' in page and "claude mcp add" in page assert 'value="http://localhost/mcp"' in page and "claude mcp add" in page
resp = await client.post("/ai-access/tokens", form={ resp = await client.post("/ai-access/tokens", form={
"name": "laptop", "role": "admin", "expires_in_days": ""}) "name": "laptop", "role": "admin", "expires_in_days": ""}, headers=csrf)
assert resp.status_code == 400 assert resp.status_code == 400
assert "higher role" in await resp.get_data(as_text=True) assert "higher role" in await resp.get_data(as_text=True)
resp = await client.post("/ai-access/tokens", form={ resp = await client.post("/ai-access/tokens", form={
"name": "laptop", "role": "operator", "expires_in_days": "30"}) "name": "laptop", "role": "operator", "expires_in_days": "30"}, headers=csrf)
assert resp.status_code == 200 assert resp.status_code == 200
created = await resp.get_data(as_text=True) created = await resp.get_data(as_text=True)
raw = re.search(r'value="(stw_[^"]+)"', created).group(1) raw = re.search(r'value="(stw_[^"]+)"', created).group(1)
@@ -163,7 +165,7 @@ def test_routes_create_list_and_revoke(app):
assert "laptop" in page and token.prefix in page assert "laptop" in page and token.prefix in page
assert raw not in page assert raw not in page
resp = await client.post(f"/ai-access/tokens/{token.id}/revoke") resp = await client.post(f"/ai-access/tokens/{token.id}/revoke", headers=csrf)
assert resp.status_code == 302 assert resp.status_code == 302
assert await api_tokens.resolve_token(app, raw) is None assert await api_tokens.resolve_token(app, raw) is None
+13 -2
View File
@@ -15,6 +15,13 @@ def make_user(role=UserRole.admin):
) )
async def with_csrf(client) -> dict:
"""Seed the session's forged-request token; returns the header to send."""
async with client.session_transaction() as sess:
sess["_csrf"] = "test-token"
return {"X-CSRF-Token": "test-token"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_page_returns_200(client): async def test_login_page_returns_200(client):
response = await client.get("/login") response = await client.get("/login")
@@ -32,9 +39,11 @@ async def test_login_redirects_to_setup_when_no_users(client, app):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_success_redirects_to_dashboard(client, app): async def test_login_success_redirects_to_dashboard(client, app):
user = make_user() user = make_user()
headers = await with_csrf(client)
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \ with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)): patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)):
response = await client.post("/login", form={"username": "admin", "password": "password"}) response = await client.post("/login", form={"username": "admin", "password": "password"},
headers=headers)
assert response.status_code == 302 assert response.status_code == 302
assert "/" in response.headers["Location"] assert "/" in response.headers["Location"]
@@ -42,9 +51,11 @@ async def test_login_success_redirects_to_dashboard(client, app):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_failure_returns_400(client, app): async def test_login_failure_returns_400(client, app):
user = make_user() user = make_user()
headers = await with_csrf(client)
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \ with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)): patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)):
response = await client.post("/login", form={"username": "admin", "password": "wrong"}) response = await client.post("/login", form={"username": "admin", "password": "wrong"},
headers=headers)
assert response.status_code == 400 assert response.status_code == 400
+81
View File
@@ -0,0 +1,81 @@
"""Forged-request protection: every unsafe browser request carries the
session's token, and every POST form in the templates sends it."""
import pathlib
import re
from unittest.mock import AsyncMock, patch
import pytest
import steward
from steward.auth import csrf
_REPO = pathlib.Path(steward.__file__).parent.parent
_FORM_TAG = re.compile(r"<form\b[^>]*>", re.S | re.I)
_POST = re.compile(r"""\bmethod\s*=\s*["']?post\b""", re.I)
def test_every_post_form_carries_the_token():
files = sorted((_REPO / "steward" / "templates").rglob("*.html")) + \
sorted((_REPO / "plugins").rglob("templates/**/*.html"))
missing = []
for f in files:
text = f.read_text()
for m in _FORM_TAG.finditer(text):
if _POST.search(m.group(0)) and \
not text[m.end():].lstrip().startswith("{{ csrf_field() }}"):
line = text.count("\n", 0, m.start()) + 1
missing.append(f"{f.relative_to(_REPO)}:{line}")
assert not missing, "POST forms without {{ csrf_field() }}: " + ", ".join(missing)
@pytest.mark.asyncio
async def test_post_without_token_is_refused(client):
response = await client.post("/login", form={"username": "a", "password": "b"})
assert response.status_code == 400
assert "did not come from Steward" in await response.get_data(as_text=True)
@pytest.mark.asyncio
async def test_post_with_wrong_token_is_refused(client):
async with client.session_transaction() as sess:
sess["_csrf"] = "right"
response = await client.post("/login", form={"username": "a", "password": "b"},
headers={"X-CSRF-Token": "wrong"})
assert response.status_code == 400
@pytest.mark.asyncio
async def test_form_field_token_is_accepted_and_rotated_at_login(client):
from tests.test_auth import make_user
async with client.session_transaction() as sess:
sess["_csrf"] = "before-login"
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=make_user())):
response = await client.post("/login", form={
"username": "admin", "password": "password", "csrf_token": "before-login"})
assert response.status_code == 302
async with client.session_transaction() as sess:
assert sess.get("_csrf") != "before-login"
@pytest.mark.asyncio
async def test_login_page_renders_token_into_form_and_meta(client):
page = await (await client.get("/login")).get_data(as_text=True)
async with client.session_transaction() as sess:
token = sess["_csrf"]
assert f'name="csrf_token" value="{token}"' in page
assert f'<meta name="csrf-token" content="{token}">' in page
@pytest.mark.asyncio
async def test_bearer_ingest_is_exempt(app):
async with app.test_request_context("/plugins/host_agent/ingest", method="POST"):
assert await csrf.check_request() is None
async with app.test_request_context("/hosts/", method="POST"):
assert (await csrf.check_request()).status_code == 400
@pytest.mark.asyncio
async def test_get_needs_no_token(app):
async with app.test_request_context("/hosts/", method="GET"):
assert await csrf.check_request() is None