feat(auth): forged browser requests are refused (#5031)
#5031 "Forged-request protection: SameSite cookie, per-session CSRF token on every non-GET, HTMX header". A page on another site could make a logged-in browser POST to Steward with the session cookie attached. - steward/auth/csrf.py: a per-session token, checked in one before_request for every POST/PUT/PATCH/DELETE (header X-CSRF-Token or form field csrf_token, constant-time compare). The bearer-authenticated host-agent ingest is exempt; /mcp never reaches Quart. - Every POST form renders {{ csrf_field() }}; base.html carries the token in a meta tag and adds it to every HTMX request; the dashboard's fetch() calls send it. - The session cookie is SameSite=Lax; login issues a fresh token. - PUBLIC_BASE_URL is now loaded from Settings at startup (it was only set when Settings → General was saved). - tests/test_csrf.py: refusal, acceptance, rotation, the ingest exemption, and a guard that every POST form in the templates carries the field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,19 +54,19 @@
|
||||
<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;"
|
||||
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">
|
||||
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
|
||||
</form>
|
||||
<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."
|
||||
onsubmit="return confirm(this.dataset.msg);">
|
||||
onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="images">
|
||||
<button type="submit" class="btn btn-sm">Prune unused images</button>
|
||||
</form>
|
||||
<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."
|
||||
onsubmit="return confirm(this.dataset.msg);">
|
||||
onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="system">
|
||||
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
|
||||
</form>
|
||||
|
||||
@@ -20,17 +20,17 @@
|
||||
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
|
||||
{% if session.user_role == 'admin' %}
|
||||
{% 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 }}">
|
||||
<button type="submit" class="btn btn-sm" title="Refresh agent.py + restart (token preserved)">Update agent</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<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>
|
||||
</form>
|
||||
<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>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -49,7 +49,7 @@
|
||||
Connects over a one-time bootstrap user + password (not stored).
|
||||
</p>
|
||||
<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 }}">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<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;">
|
||||
<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>
|
||||
<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 }}">
|
||||
<button type="submit" class="btn btn-sm">Generate managed key</button>
|
||||
</form>
|
||||
@@ -115,7 +115,7 @@
|
||||
</p>
|
||||
{# Provision: brand-new host (creates steward account + key over a one-time password) #}
|
||||
<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 }}">
|
||||
<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;">
|
||||
@@ -129,7 +129,7 @@
|
||||
<button type="submit" class="btn btn-sm">Provision</button>
|
||||
</form>
|
||||
{# 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 }}">
|
||||
<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>
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
{% if reg and session.user_role == 'admin' %}
|
||||
<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>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<div class="card">
|
||||
<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;">
|
||||
<label>Host name</label>
|
||||
<input type="text" name="name" required>
|
||||
@@ -63,7 +63,7 @@
|
||||
<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>.
|
||||
</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/">
|
||||
<button type="submit" class="btn btn-sm">Generate managed key</button>
|
||||
</form>
|
||||
@@ -85,7 +85,7 @@
|
||||
agent. Run this once per host.
|
||||
</p>
|
||||
<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;">
|
||||
<label>Bootstrap user</label>
|
||||
@@ -111,7 +111,7 @@
|
||||
mints a fresh token, and installs the agent. No <code>curl | sh</code> needed.
|
||||
</p>
|
||||
<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;">
|
||||
<label>Report interval (s)</label>
|
||||
@@ -129,7 +129,7 @@
|
||||
so identity is preserved.
|
||||
</p>
|
||||
<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>
|
||||
<button type="submit" class="btn">Update</button>
|
||||
</form>
|
||||
@@ -156,12 +156,12 @@
|
||||
<td class="td-actions">
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token"
|
||||
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>
|
||||
</form>
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
|
||||
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>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "{{ w.name }}"?')">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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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); });
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────── #}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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 "{{ orphan.name }}"? 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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -132,6 +132,8 @@ def test_routes_create_list_and_revoke(app):
|
||||
sess["user_id"] = uid
|
||||
sess["user_role"] = "operator"
|
||||
sess["username"] = "route-test"
|
||||
sess["_csrf"] = "test-token"
|
||||
csrf = {"X-CSRF-Token": "test-token"}
|
||||
|
||||
resp = await client.get("/ai-access/")
|
||||
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
|
||||
|
||||
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 "higher role" in await resp.get_data(as_text=True)
|
||||
|
||||
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
|
||||
created = await resp.get_data(as_text=True)
|
||||
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 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 await api_tokens.resolve_token(app, raw) is None
|
||||
|
||||
|
||||
+13
-2
@@ -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
|
||||
async def test_login_page_returns_200(client):
|
||||
response = await client.get("/login")
|
||||
@@ -32,9 +39,11 @@ async def test_login_redirects_to_setup_when_no_users(client, app):
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success_redirects_to_dashboard(client, app):
|
||||
user = make_user()
|
||||
headers = await with_csrf(client)
|
||||
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)):
|
||||
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 "/" in response.headers["Location"]
|
||||
|
||||
@@ -42,9 +51,11 @@ async def test_login_success_redirects_to_dashboard(client, app):
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_failure_returns_400(client, app):
|
||||
user = make_user()
|
||||
headers = await with_csrf(client)
|
||||
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)):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user