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
+13
View File
@@ -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
+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:
"""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
+2 -2
View File
@@ -59,7 +59,7 @@
<td class="td-actions">
{% if row.state == 'active' %}
<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>
</form>
{% endif %}
@@ -79,7 +79,7 @@
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Create a token</div>
<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">
<label for="tok-name">Name</label>
<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 class="td-actions">
{% 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>
</form>
{% endif %}
@@ -83,12 +83,12 @@
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }}</td>
<td class="td-actions">
<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">
{% if rule.enabled %}Disable{% else %}Enable{% endif %}
</button>
</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"
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
</form>
+1 -1
View File
@@ -59,7 +59,7 @@
{% endif %}
</td>
<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"
onclick="return confirm('Delete window &quot;{{ w.name }}&quot;?')">Delete</button>
</form>
@@ -6,7 +6,7 @@
<div style="max-width:560px;margin:2rem auto;">
<h1 class="page-title">New Maintenance Window</h1>
<div class="card">
<form method="post" action="/alerts/maintenance">
<form method="post" action="/alerts/maintenance">{{ csrf_field() }}
<div class="form-group">
<label>Window Name</label>
+1 -1
View File
@@ -6,7 +6,7 @@
<div style="max-width:600px;margin:2rem auto;">
<h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1>
<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">
<label>Rule Name</label>
+1 -1
View File
@@ -28,7 +28,7 @@
{% if view_editable and session.user_role == 'admin' %}
<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;"
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="playbook_path" value="{{ view_path }}">
<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>
</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;">
<h3 class="section-title">Settings</h3>
<div class="form-group">
@@ -12,7 +12,7 @@
<div class="card" style="margin-bottom:1.5rem;">
<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;">
<label>Name</label>
<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 class="td-actions">
<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"
onclick="return confirm('Delete group {{ grp.name }}?')">×</button>
</form>
@@ -7,7 +7,7 @@
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
</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;">
<h3 class="section-title">Connection</h3>
<div class="form-group">
@@ -12,7 +12,7 @@
<div class="card" style="margin-bottom:1.5rem;">
<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;">
<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>
@@ -47,7 +47,7 @@
</td>
<td class="td-actions">
<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"
onclick="return confirm('Delete target {{ tgt.name }}?')">×</button>
</form>
@@ -15,7 +15,7 @@
automatically; you can also configure a local-dir source under <a href="/settings/ansible/">Settings → Ansible</a>.
</div>
{% 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' }}">
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;">
<div class="form-group" style="margin-bottom:0;">
@@ -54,7 +54,7 @@
{% if editing %}
<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="playbook_path" value="{{ playbook_path }}">
<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>
<div style="margin-left:auto;display:flex;gap:0.5rem;">
{% 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>
</form>
{% endif %}
+4 -4
View File
@@ -54,15 +54,15 @@
{% else %}due now{% endif %}
</td>
<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>
</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>
</form>
<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;"
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>
</form>
</td>
@@ -79,7 +79,7 @@
{% set declared_names = (edit_variables or []) | map(attribute='name') | list %}
<div class="card" id="form">
<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 class="form-group" style="margin-bottom:0;">
<label>Name</label>
+1 -1
View File
@@ -20,7 +20,7 @@
<div style="flex:1;height:1px;background:var(--border-mid);"></div>
</div>
{% 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 %}
<div class="form-group">
<input type="text" name="username" placeholder="Username" autofocus required>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="card">
<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>
<form method="post" action="/setup">
<form method="post" action="/setup">{{ csrf_field() }}
<div class="form-group">
<label>Username</label>
<input type="text" name="username" autofocus required>
+8
View File
@@ -3,6 +3,14 @@
<head>
<meta charset="UTF-8">
<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>
<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>
@@ -27,7 +27,7 @@
</summary>
<form action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}" method="post"
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>
<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);
if (!canEdit) return; // viewers get the static grid only
var csrf = document.querySelector('meta[name="csrf-token"]').content;
function saveLayout() {
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 };
});
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 }),
});
}
@@ -101,7 +102,7 @@
document.querySelectorAll('.add-widget-form').forEach(function (form) {
form.addEventListener('submit', function (ev) {
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 (html) {
if (!html) return;
@@ -126,7 +127,8 @@
if (!rm) return;
ev.preventDefault();
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); });
});
+6 -6
View File
@@ -37,7 +37,7 @@
<details style="position:relative;">
<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);">
<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 }}"
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
<button type="submit" class="btn btn-sm">Save</button>
@@ -45,19 +45,19 @@
</div>
</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>
</form>
{% 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>
</form>
{% endif %}
{% if my_dashboards | length > 1 %}
<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>
</form>
{% endif %}
@@ -94,7 +94,7 @@
</div>
<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>
<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>
</form>
{# Admins can also edit system dashboards #}
@@ -115,7 +115,7 @@
<div>
<div class="section-title" style="margin-bottom:0.75rem;">New Dashboard</div>
<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;">
<label>Name</label>
<input type="text" name="name" placeholder="e.g. Network Overview" required>
+2 -2
View File
@@ -32,7 +32,7 @@
</div>
</div>
<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>
</form>
</div>
@@ -50,7 +50,7 @@
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Create Share Link</div>
<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;">
<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">
+7 -7
View File
@@ -16,7 +16,7 @@
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
{% if session.user_role == 'admin' %}
<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>
</form>
{% endif %}
@@ -78,7 +78,7 @@
<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>
<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>
</form>
</div>
@@ -94,7 +94,7 @@
{% if session.user_role in ['operator', 'admin'] %}
<details style="margin-top:0.75rem;">
<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 }}">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.6rem;align-items:end;">
<div>
@@ -148,7 +148,7 @@
{% endif %}
</div>
<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">
<button type="submit" class="btn btn-ghost btn-sm">Unlink</button>
</form>
@@ -157,7 +157,7 @@
{% if ansible_sources %}
<details>
<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">
<label>Source</label>
<select name="source_name" required
@@ -202,7 +202,7 @@
</p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
{% 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">
<select name="target_id">
{% 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>
</form>
{% 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">
<button type="submit" class="btn btn-sm btn-ghost">Create target from this host</button>
</form>
+1 -1
View File
@@ -6,7 +6,7 @@
<div style="max-width:560px;margin:2rem auto;">
<h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1>
<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">
<label>Display Name</label>
<input type="text" name="name" value="{{ host.name if host else '' }}" required autofocus>
+1 -1
View File
@@ -78,7 +78,7 @@
</td>
<td class="td-actions">
<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"
onclick="return confirm('Delete {{ host.name }}?')">Delete</button>
</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;" %}
<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>
<label style="{{ lbl }}">Type</label>
+1 -1
View File
@@ -16,7 +16,7 @@
{# ── Add monitor ──────────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<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>
<label style="{{ lbl }}">Type</label>
+2 -2
View File
@@ -54,12 +54,12 @@
{% if session.user_role in ['operator', 'admin'] %}
<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>
<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;">
{{ "Pause" if m.enabled else "Resume" }}</button>
</form>
<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>
</form>
</div>
@@ -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() }}
<input type="hidden" name="download_url" value="{{ plugin.download_url }}">
<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>
@@ -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() }}
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button>
</form>
@@ -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() }}
<input type="hidden" name="download_url" value="{{ plugin.download_url }}">
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button>
@@ -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() }}
<button type="submit"
style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;">
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>
</div>
<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>
</form>
{% 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>
<span style="color:var(--text-muted);font-size:0.78rem;margin-left:0.5rem;">ed25519 — created server-side, nothing to paste.</span>
</form>
@@ -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.
</p>
<form method="post" action="/settings/ansible/credentials">
<form method="post" action="/settings/ansible/credentials">{{ csrf_field() }}
<div class="form-group">
<label>SSH private key {{ (cfg_badge if ssh_key_set else not_set_badge) | safe }}</label>
<textarea name="ssh_private_key" rows="4"
+1 -1
View File
@@ -7,7 +7,7 @@
{% set active_tab = "auth" %}
{% 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;">
{# ── OIDC ─────────────────────────────────────────────────────────────────── #}
+1 -1
View File
@@ -5,7 +5,7 @@
{% set active_tab = "general" %}
{% 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;">
<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 content %}
<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;">
{# ── General ──────────────────────────────────────────────────────────── #}
@@ -7,7 +7,7 @@
{% set active_tab = "notifications" %}
{% 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;">
{# ── 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>
{% 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 ────────────────────────────────────────────────────────── #}
<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/"
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.')"
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>
</form>
</div>
@@ -135,7 +135,7 @@
{% if orphan.removable %}
<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.');"
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>
</form>
{% endif %}
+2 -2
View File
@@ -20,7 +20,7 @@
<div style="max-width:560px;">
{# ── Schedule ─────────────────────────────────────────────────────────────── #}
<form method="post" action="/settings/reports/">
<form method="post" action="/settings/reports/">{{ csrf_field() }}
<div class="card" style="margin-bottom:1.25rem;">
<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;">
Send a report immediately regardless of schedule. Useful for testing.
</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>
</form>
</div>
+1 -1
View File
@@ -24,7 +24,7 @@
</div>
{% endmacro %}
<form method="post" action="/settings/thresholds/">
<form method="post" action="/settings/thresholds/">{{ csrf_field() }}
<div class="card" style="max-width:640px;">
<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;">