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
+8 -8
View File
@@ -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>