hub v0.3.8 — CSRF protection + secure session model

- server.go: replace literal hub_session=authenticated with random 64-char hex
  session tokens stored server-side (hubSession map + sync.RWMutex); per-session
  CSRF tokens; CleanupSessions goroutine; SameSite=Lax+Secure cookie; CSRF
  validation in ServeHTTP; csrfToken/csrfField helpers
- configs.go: add html/template import; pass CSRFField/CSRFToken to all template
  renders; renderConfigForm gains r *http.Request parameter
- config_form.html: {{.CSRFField}} in form
- customer_unified.html: meta csrf-token + csrfHeaders() JS; {{.CSRFField}} in
  all 5 POST forms; csrfHeaders() on 3 fetch calls
- main.go: start CleanupSessions goroutine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 16:39:14 +01:00
parent da991fad57
commit 67f53a4ccd
6 changed files with 187 additions and 21 deletions
@@ -6,6 +6,8 @@
<title>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} — Felhom Hub</title>
<link rel="stylesheet" href="/style.css">
{{if .HasReports}}<meta http-equiv="refresh" content="60">{{end}}
<meta name="csrf-token" content="{{.CSRFToken}}">
<script>function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}</script>
</head>
<body>
<div class="container">
@@ -52,20 +54,24 @@
<a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a>
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Unblock</button>
</form>
{{else}}
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm">Block</button>
</form>
{{end}}
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
{{.CSRFField}}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{{else}}
<form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Create Config</button>
</form>
{{end}}
@@ -301,6 +307,7 @@
</div>
<form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm">Regenerate</button>
</form>
</div>
@@ -560,7 +567,7 @@
btn.disabled = true;
btn.textContent = 'Triggering...';
msg.style.display = 'none';
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST'})
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
@@ -591,7 +598,7 @@
btn.disabled = true;
btn.textContent = 'Pushing...';
msg.style.display = 'none';
fetch('/customers/' + customerID + '/push-config', {method: 'POST'})
fetch('/customers/' + customerID + '/push-config', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
@@ -622,7 +629,7 @@
btn.disabled = true;
btn.textContent = 'Pulling...';
msg.style.display = 'none';
fetch('/customers/' + customerID + '/pull-config', {method: 'POST'})
fetch('/customers/' + customerID + '/pull-config', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {