v0.23.0 — CSRF protection on all browser-facing POST endpoints

Controller:
- internal/web/csrf.go (new): CsrfProtect middleware, csrfToken/csrfField helpers
- auth.go: per-session CSRF token (csrfToken field, csrfTokenForSession method)
- server.go: executeTemplate wrapper auto-injects CSRFField+CSRFToken
- main.go: wire CsrfProtect on all routes; bump to v0.23.0
- handlers.go, storage_handlers.go, handler_restore.go: executeTemplate
- All templates: CSRFField in forms, meta csrf-token, csrfHeaders() JS helper,
  fetch calls updated; sendBeacon→fetch+keepalive in storage_attach.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 16:38:56 +01:00
parent ade01470d0
commit 02650e3202
20 changed files with 1143 additions and 75 deletions
@@ -153,7 +153,7 @@ async function saveOptionalConfig(stackName) {
try {
const resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({values: values})
});
const data = await resp.json();
@@ -580,7 +580,7 @@ function toggleTier(header) {
function triggerCrossDriveBackup(stackName, btn) {
btn.disabled = true;
btn.textContent = 'Fut...';
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST'})
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) {
@@ -602,7 +602,7 @@ function triggerCrossDriveBackup(stackName, btn) {
function triggerAllCrossDrive(btn) {
btn.disabled = true;
btn.textContent = 'Indítás...';
fetch('/api/backup/cross-drive/run-all', {method: 'POST'})
fetch('/api/backup/cross-drive/run-all', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) {
@@ -625,7 +625,7 @@ function triggerBackupFromPage() {
const btn = document.getElementById('backup-page-btn');
btn.disabled = true;
btn.textContent = 'Mentés indítása...';
fetch('/api/backup/run', { method: 'POST' })
fetch('/api/backup/run', { method: 'POST', headers: csrfHeaders() })
.then(r => r.json())
.then(data => {
if (data.ok) {
@@ -782,6 +782,11 @@ function submitRestore() {
form.method = 'POST';
form.action = '/backup/restore';
var fc = document.createElement('input');
fc.type = 'hidden'; fc.name = '_csrf';
fc.value = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
form.appendChild(fc);
var f1 = document.createElement('input');
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
form.appendChild(f1);
@@ -118,6 +118,7 @@
</div>
{{else}}
<form method="post" action="/settings/cross-backup/{{.Meta.Slug}}">
{{.CSRFField}}
<div class="settings-grid" style="margin-bottom:1rem">
<div class="settings-row">
<span class="settings-label">Engedélyezve</span>
@@ -375,7 +376,7 @@ function onScheduleChange() {
function triggerCrossDriveBackup(stackName, btn) {
btn.disabled = true;
btn.textContent = 'Mentés folyamatban...';
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST'})
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) {
@@ -456,7 +457,7 @@ function deleteStaleData(stackName, stalePath, btn) {
fetch('/api/storage/stale-cleanup', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
})
.then(function(r) { return r.json(); })
@@ -553,7 +554,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
try {
var resp = await fetch('/api/stacks/' + stackName + '/deploy', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({values: values})
});
var data = await resp.json();
@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — Felhom.eu</title>
<link rel="stylesheet" href="/static/style.css">
<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>
<nav class="sidebar">
@@ -75,7 +77,7 @@
try {
const resp = await fetch('/api/sync', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (toast) {
@@ -108,7 +110,7 @@
try {
const resp = await fetch('/api/stacks/' + name + '/' + action, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (!data.ok) {
@@ -174,7 +176,7 @@
try {
var resp = await fetch('/api/stacks/' + name, {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD})
});
var data = await resp.json();
@@ -286,7 +288,7 @@
try {
var resp = await fetch('/api/stacks/' + name + '/remove', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
});
var data = await resp.json();
@@ -128,7 +128,7 @@ function startMigrate() {
fetch('/api/storage/migrate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({stack_name: stackName, target_path: targetPath, auto_delete_stale: autoDelete})
})
.then(function(r){ return r.json(); })
@@ -236,7 +236,7 @@ function deleteOldMigrationData() {
fetch('/api/storage/stale-cleanup', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({stack_name: stackName, stale_path: oldPath})
})
.then(function(r) { return r.json(); })
@@ -128,7 +128,7 @@ function startDriveMigrate() {
fetch('/api/storage/migrate-drive', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({source_path: sourcePath, dest_path: destPath})
})
.then(function(r){ return r.json(); })
@@ -205,7 +205,7 @@
btn.innerHTML = '<span class="spinner"></span> Visszaállítás indítása...';
if (skipBtn) skipBtn.style.display = 'none';
fetch('/api/restore/all', { method: 'POST' })
fetch('/api/restore/all', { method: 'POST', headers: csrfHeaders() })
.then(function(resp) { return resp.json(); })
.then(function(data) {
if (data.ok) {
@@ -229,7 +229,7 @@
function skipRestore() {
if (!confirm('Biztosan ki szeretné hagyni a visszaállítást? A vezérlőpult üres alkalmazáslistával fog elindulni.')) return;
fetch('/api/restore/skip', { method: 'POST' })
fetch('/api/restore/skip', { method: 'POST', headers: csrfHeaders() })
.then(function(resp) { return resp.json(); })
.then(function(data) {
if (data.ok) {
@@ -243,7 +243,7 @@
function finishRestore(e) {
e.preventDefault();
fetch('/api/restore/skip', { method: 'POST' })
fetch('/api/restore/skip', { method: 'POST', headers: csrfHeaders() })
.then(function() { window.location.href = '/'; })
.catch(function() { window.location.href = '/'; });
}
@@ -131,7 +131,7 @@ function checkUpdate() {
btn.disabled = true;
btn.textContent = 'Ellenőrzés...';
msg.style.display = 'none';
fetch('/api/selfupdate/check', {method:'POST'})
fetch('/api/selfupdate/check', {method:'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
@@ -161,7 +161,7 @@ function triggerUpdate() {
if (checkBtn) checkBtn.disabled = true;
msg.textContent = 'Frissítés folyamatban...';
msg.style.display = 'inline';
fetch('/api/selfupdate/update', {method:'POST'})
fetch('/api/selfupdate/update', {method:'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
@@ -248,6 +248,7 @@ function pollUntilBack() {
<div class="storage-path-actions">
<form method="POST" action="/settings/storage/remove" style="display:inline"
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Label}} ({{.Path}}) meghajtót a rendszerből?\n\nA meghajtó adatai NEM törlődnek.')">
{{$.CSRFField}}
<input type="hidden" name="storage_path" value="{{.Path}}">
<button type="submit" class="btn btn-xs btn-outline">Eltávolítás a rendszerből</button>
</form>
@@ -300,18 +301,21 @@ function pollUntilBack() {
<div class="storage-path-actions">
{{if not .IsDefault}}
<form method="POST" action="/settings/storage/default" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="storage_path" value="{{.Path}}">
<button type="submit" class="btn btn-xs btn-outline">Legyen alapértelmezett</button>
</form>
{{end}}
{{if .Schedulable}}
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="storage_path" value="{{.Path}}">
<input type="hidden" name="schedulable" value="false">
<button type="submit" class="btn btn-xs btn-outline">Letiltás</button>
</form>
{{else}}
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="storage_path" value="{{.Path}}">
<input type="hidden" name="schedulable" value="true">
<button type="submit" class="btn btn-xs btn-outline">Engedélyezés</button>
@@ -323,6 +327,7 @@ function pollUntilBack() {
{{if and (not .IsDefault) (eq .AppCount 0)}}
<form method="POST" action="/settings/storage/remove" style="display:inline"
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Path}} adattárolót?')">
{{$.CSRFField}}
<input type="hidden" name="storage_path" value="{{.Path}}">
<button type="submit" class="btn btn-xs btn-danger-outline">Eltávolítás</button>
</form>
@@ -349,6 +354,7 @@ function pollUntilBack() {
<details class="storage-add-details">
<summary class="btn btn-sm btn-outline" style="margin-top:.75rem;cursor:pointer">Már csatlakoztatott tárhely hozzáadása kézzel</summary>
<form method="POST" action="/settings/storage/add" class="storage-add-form">
{{.CSRFField}}
<div class="form-group">
<label for="storage_path">Elérési út</label>
<input type="text" id="storage_path" name="storage_path" class="form-control"
@@ -375,6 +381,7 @@ function pollUntilBack() {
{{if .AuthEnabled}}
{{if .PasswordError}}<div class="alert alert-error">{{.PasswordError}}</div>{{end}}
<form method="POST" action="/settings/password">
{{.CSRFField}}
<div class="form-group">
<label for="current_password">Jelenlegi jelszó</label>
<input type="password" id="current_password" name="current_password" required
@@ -406,6 +413,7 @@ function pollUntilBack() {
{{if .NotificationSuccess}}<div class="alert alert-info">{{.NotificationSuccess}}</div>{{end}}
{{if .NotificationError}}<div class="alert alert-error">{{.NotificationError}}</div>{{end}}
<form method="POST" action="/settings/notifications">
{{.CSRFField}}
<div class="form-group">
<label for="notification_email">E-mail cím</label>
<input type="email" id="notification_email" name="notification_email"
@@ -531,7 +539,9 @@ function pollUntilBack() {
function editStorageLabel(path, currentLabel) {
var wrap = document.getElementById('label-wrap-' + path);
if (!wrap) return;
var csrfTok = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
wrap.innerHTML = '<form method="POST" action="/settings/storage/label" style="display:inline-flex;gap:.5rem;align-items:center">' +
'<input type="hidden" name="_csrf" value="' + csrfTok + '">' +
'<input type="hidden" name="storage_path" value="' + path + '">' +
'<input type="text" name="storage_label" class="form-control" value="' + currentLabel.replace(/"/g, '&quot;') + '" style="width:200px;padding:.3rem .5rem;font-size:.9rem" maxlength="50">' +
'<button type="submit" class="btn btn-xs btn-primary">OK</button>' +
@@ -546,7 +556,7 @@ function storageDisconnect(path, label, appCount) {
if (!confirm(msg)) return;
fetch('/api/storage/disconnect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({path: path})
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.ok) {
@@ -562,7 +572,7 @@ function storageReconnect(path) {
if (actionsDiv) actionsDiv.innerHTML = '<span class="form-hint">Csatlakoztatás...</span>';
fetch('/api/storage/reconnect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({path: path})
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.ok) {
@@ -579,7 +589,7 @@ function storageReconnect(path) {
function storageRestartApps(path) {
fetch('/api/storage/restart-apps', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({path: path})
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.ok) {
@@ -169,9 +169,9 @@ function scanDisks() {
// Clean up any stale raw mounts from interrupted previous sessions first,
// so the device appears as available in the scan results.
fetch('/api/storage/attach/cancel', {method:'POST'})
fetch('/api/storage/attach/cancel', {method:'POST', headers: csrfHeaders()})
.catch(function(){}) // ignore cancel errors
.then(function() { return fetch('/api/storage/scan', {method:'POST'}); })
.then(function() { return fetch('/api/storage/scan', {method:'POST', headers: csrfHeaders()}); })
.then(function(r){ return r.json(); })
.then(function(data) {
btn.textContent = '🔍 Meghajtók keresése';
@@ -274,7 +274,7 @@ function mountRawAndBrowse(devicePath, fsType) {
fetch('/api/storage/attach/mount-raw', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({device_path: devicePath})
}).then(function(r){ return r.json(); })
.then(function(data) {
@@ -407,7 +407,7 @@ function createDir() {
fetch('/api/storage/attach/mkdir', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({path: currentBrowsePath, name: name})
}).then(function(r){ return r.json(); })
.then(function(data) {
@@ -447,7 +447,7 @@ function backToBrowse() {
function cancelAttach() {
// Cleanup raw mount
fetch('/api/storage/attach/cancel', {method:'POST'}).catch(function(){});
fetch('/api/storage/attach/cancel', {method:'POST', headers: csrfHeaders()}).catch(function(){});
window.location.href = '/settings';
}
@@ -482,7 +482,7 @@ document.getElementById('attach-form').addEventListener('submit', function(e) {
fetch('/api/storage/attach', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify(body)
}).then(function(r){ return r.json(); })
.then(function(data) {
@@ -572,8 +572,8 @@ function escapeAttr(s) {
// Cleanup on page unload (best-effort)
window.addEventListener('beforeunload', function() {
if (rawMountPath && !document.getElementById('wizard-done').style.display !== 'none') {
// Best-effort cleanup via sendBeacon
navigator.sendBeacon('/api/storage/attach/cancel');
// Best-effort cleanup via fetch (sendBeacon can't send CSRF headers)
fetch('/api/storage/attach/cancel', {method:'POST', headers: csrfHeaders(), keepalive: true}).catch(function(){});
}
});
</script>
@@ -122,7 +122,7 @@ function scanDisks() {
errEl.style.display = 'none';
resultEl.style.display = 'none';
fetch('/api/storage/scan', {method:'POST'})
fetch('/api/storage/scan', {method:'POST', headers: csrfHeaders()})
.then(function(r){ return r.json(); })
.then(function(data) {
btn.textContent = '🔍 Meghajtók keresése';
@@ -242,7 +242,7 @@ document.getElementById('init-form').addEventListener('submit', function(e) {
fetch('/api/storage/init', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify(body)
}).then(function(r){ return r.json(); })
.then(function(data) {