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:
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user