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
+39
View File
@@ -1,5 +1,44 @@
## Changelog
### v0.23.0 — CSRF Protection (2026-02-21)
**CSRF (Cross-Site Request Forgery) protection on all browser-facing POST endpoints — controller and hub.**
**Controller changes:**
- New `internal/web/csrf.go`: `CsrfProtect` HTTP middleware validates CSRF tokens on all state-mutating requests (POST/DELETE/PATCH).
- Reads token from `_csrf` form field or `X-CSRF-Token` request header.
- Exempt paths: `Authorization: Bearer` requests (selfupdate, config/apply hub→controller calls) — browsers cannot auto-send Bearer headers, so no CSRF risk.
- Auth-disabled mode (no password set): CSRF check is skipped entirely.
- On rejection: JSON error for `/api/` paths, HTTP 403 text for page routes.
- `internal/web/auth.go`: `session` struct gains a `csrfToken string` field. `createSession()` generates a second 32-byte random CSRF token alongside the session token. New `csrfTokenForSession(sessionToken)` method returns the CSRF token for a given session.
- `internal/web/server.go`: New `executeTemplate(w, r, name, data)` wrapper auto-injects `CSRFField` (`template.HTML` hidden input) and `CSRFToken` (raw string) into every page render data map.
- `cmd/controller/main.go`: All route registrations wrapped with `webServer.CsrfProtect(...)` middleware. Version bumped to `v0.23.0`.
- All handlers (`handlers.go`, `storage_handlers.go`, `handler_restore.go`): Switched from `s.render(w, ...)` to `s.executeTemplate(w, r, ...)`.
- All templates updated:
- `layout.html`: Added `<meta name="csrf-token">` and inline `csrfHeaders()` JS helper (returns `{'X-CSRF-Token': ...}`) in `<head>` (before page-specific scripts). Updated 4 fetch POST/DELETE calls.
- `settings.html`: Added `{{$.CSRFField}}` to 5 forms inside `{{range .StoragePaths}}` (must use `$` for outer scope inside range). Added `{{.CSRFField}}` to 3 page-level forms. Inline-label form uses `document.querySelector('meta[name="csrf-token"]').content`. Updated 5 fetch calls.
- `deploy.html`: Added `{{.CSRFField}}` to cross-backup form. Updated 3 fetch calls.
- `backups.html`: Updated 3 fetch calls. Dynamically-created restore form injects `_csrf` from meta tag.
- `storage_init.html`, `storage_attach.html`, `migrate.html`, `migrate_drive.html`, `app_info.html`, `restore.html`: All fetch calls updated.
- `storage_attach.html`: Replaced `navigator.sendBeacon()` with `fetch(..., {keepalive: true})``sendBeacon` cannot send custom headers, making CSRF impossible.
**Hub changes (v0.3.8):**
- `internal/web/server.go`: Replaced insecure literal `hub_session=authenticated` cookie with proper server-side session map.
- New `hubSession` struct with `csrfToken string` and `expiresAt time.Time`.
- `sessions map[string]*hubSession` + `sessionsMu sync.RWMutex` on `Server` struct.
- `handleLogin`: Generates cryptographically random 64-char hex session token + 64-char hex CSRF token. Cookie gains `SameSite=Lax` and `Secure` (when TLS) attributes. Session expires after 7 days.
- `RequireAuth`: Validates session token against map (constant-time compare), redirects to `/login` on failure.
- `CleanupSessions(ctx)`: Goroutine that purges expired sessions every hour.
- CSRF validation block at top of `ServeHTTP`: checks `X-CSRF-Token` header or `_csrf` form field on POST/DELETE/PATCH. Skips when no session cookie (Basic Auth / API path).
- `csrfToken(r)`, `csrfField(r)` helpers for template data injection.
- `internal/web/configs.go`: Added `html/template` import. All template render calls pass `CSRFField template.HTML` and/or `CSRFToken string`. `renderConfigForm` gains `r *http.Request` parameter.
- Templates updated:
- `config_form.html`: Added `{{.CSRFField}}` inside the `<form>`.
- `customer_unified.html`: Added `<meta name="csrf-token">` + inline `csrfHeaders()` in `<head>`. Added `{{.CSRFField}}` to all 5 POST forms (unblock, block, delete config, create-config, regen-password). Updated 3 JS fetch POST calls (trigger-update, push-config, pull-config).
- `cmd/hub/main.go`: Started `go webServer.CleanupSessions(ctx)` goroutine.
### v0.22.3 — Hub Asset Sync (2026-02-21)
**Hub-managed asset downloads**
+855
View File
@@ -0,0 +1,855 @@
# TASK: CSRF Protection on POST Endpoints
**Controller:** v0.22.3 → v0.23.0
**Hub:** v0.3.7 → v0.3.8
## Overview
Add CSRF (Cross-Site Request Forgery) protection to all browser-facing POST endpoints in both the controller and the hub. Currently, any page on the internet can craft a form that POSTs to the controller/hub if the user has a valid session cookie — no origin verification exists.
**Approach: Synchronizer Token Pattern** (per-session CSRF token stored server-side, embedded in forms, validated on POST). This matches the existing setup wizard implementation in `controller/internal/setup/csrf.go` but is more secure: tokens are tied to server-side sessions rather than using the double-submit cookie pattern.
**Key principle:** Bearer-token-authenticated API endpoints (selfupdate, config/apply) do NOT need CSRF protection — browsers never auto-send `Authorization` headers. Only cookie/session-authenticated POST endpoints need protection.
---
## Security Audit Summary
### Controller (current state)
| Item | Status | Notes |
|------|--------|-------|
| Session cookie HttpOnly | OK | `HttpOnly: true` |
| Session cookie SameSite | PARTIAL | `SameSite=Lax` (good defense-in-depth but not sufficient alone) |
| Session cookie Secure | OK | Set when TLS detected |
| Random session tokens | OK | 64-char hex (32 bytes) |
| CSRF on web form POSTs | MISSING | **None — vulnerable** |
| CSRF on JS-driven API POSTs | MISSING | **None — vulnerable** |
| Bearer-only endpoints exempt | n/a | selfupdate + config endpoints accept Bearer OR session |
### Hub (current state — MORE CRITICAL)
| Item | Status | Notes |
|------|--------|-------|
| Session cookie HttpOnly | OK | `HttpOnly: true` |
| Session cookie SameSite | MISSING | **Not set** (browser default = Lax in modern browsers, but should be explicit) |
| Session cookie Secure | MISSING | **Not set** |
| Random session tokens | MISSING | **Cookie value is literal string `"authenticated"`** — any cookie injection = full session |
| CSRF on web form POSTs | MISSING | **None — vulnerable** |
| CSRF on JS-driven POSTs | MISSING | **None — vulnerable** |
**Hub session model is fundamentally weak:** The `hub_session=authenticated` cookie contains no randomness. This means:
1. Any XSS or subdomain-based cookie injection can forge a session
2. There is no way to invalidate individual sessions
3. Combined with no CSRF = highly vulnerable
This task fixes both the CSRF gap AND the hub session model.
---
## Part 1: Controller CSRF (v0.23.0)
### Endpoint Map: All State-Changing (POST/DELETE) Endpoints
#### Web Form POSTs (need CSRF token in hidden field)
| # | Method | Path | Handler | Auth |
|---|--------|------|---------|------|
| 1 | POST | `/settings/password` | `handlePasswordChange` | Session |
| 2 | POST | `/settings/notifications` | `handleNotificationSettings` | Session |
| 3 | POST | `/settings/notifications/test` | `handleTestNotification` | Session |
| 4 | POST | `/settings/storage/add` | `handleStorageAdd` | Session |
| 5 | POST | `/settings/storage/remove` | `handleStorageRemove` | Session |
| 6 | POST | `/settings/storage/default` | `handleStorageDefault` | Session |
| 7 | POST | `/settings/storage/schedulable` | `handleStorageSchedulable` | Session |
| 8 | POST | `/settings/storage/label` | `handleStorageLabel` | Session |
| 9 | POST | `/settings/cross-backup/{name}` | `handleCrossBackupSave` | Session |
| 10 | POST | `/backup/restore` | `handleRestore` | Session |
#### JSON API POSTs called via `fetch()` from UI pages (need X-CSRF-Token header)
| # | Method | Path | Handler | Notes |
|---|--------|------|---------|-------|
| 11 | POST | `/api/stacks/{name}/deploy` | `handleDeploy` | Deploy page JS |
| 12 | POST | `/api/stacks/{name}/start` | `handleStart` | Dashboard buttons |
| 13 | POST | `/api/stacks/{name}/stop` | `handleStop` | Dashboard buttons |
| 14 | POST | `/api/stacks/{name}/restart` | `handleRestart` | Dashboard buttons |
| 15 | POST | `/api/stacks/{name}/update` | `handleUpdate` | Dashboard buttons |
| 16 | POST | `/api/stacks/{name}/optional-config` | `handleOptionalConfig` | App info page |
| 17 | POST | `/api/stacks/{name}/remove` | `handleRemove` | Remove modal |
| 18 | DELETE | `/api/stacks/{name}` | `handleDelete` | Delete modal |
| 19 | POST | `/api/stacks/{name}/cross-backup` | `handleCrossBackupAPI` | Deploy page |
| 20 | POST | `/api/stacks/{name}/cross-backup/run` | `handleCrossBackupRun` | Backup page |
| 21 | POST | `/api/sync` | `handleSync` | Dashboard button |
| 22 | POST | `/api/backup/run` | `handleBackupRun` | Backup page |
| 23 | POST | `/api/backup/cross-drive/run-all` | `handleCrossDriveRunAll` | Backup page |
| 24 | POST | `/api/stacks/rescan` | `handleRescan` | Internal |
| 25 | POST | `/api/storage/scan` | `handleStorageScan` | Init wizard |
| 26 | POST | `/api/storage/init` | `handleStorageInit` | Init wizard |
| 27 | POST | `/api/storage/attach/mount-raw` | `handleAttachMountRaw` | Attach wizard |
| 28 | POST | `/api/storage/attach/mkdir` | `handleAttachMkdir` | Attach wizard |
| 29 | POST | `/api/storage/attach` | `handleAttachFinalize` | Attach wizard |
| 30 | POST | `/api/storage/attach/cancel` | `handleAttachCancel` | Attach wizard |
| 31 | POST | `/api/storage/migrate` | `handleMigrate` | Migration page |
| 32 | POST | `/api/storage/stale-cleanup` | `handleStaleCleanup` | Deploy page |
| 33 | POST | `/api/storage/disconnect` | `handleDisconnect` | Settings |
| 34 | POST | `/api/storage/reconnect` | `handleReconnect` | Settings |
| 35 | POST | `/api/storage/restart-apps` | `handleRestartApps` | Settings |
| 36 | POST | `/api/storage/migrate-drive` | `handleMigrateDrive` | Drive migration |
| 37 | POST | `/api/storage/decommission/remove` | `handleDecommissionRemove` | Settings |
| 38 | POST | `/api/assets/sync` | `handleAssetSync` | Internal |
| 39 | POST | `/api/restore/all` | `handleRestoreAll` | DR page |
| 40 | POST | `/api/restore/skip` | `handleRestoreSkip` | DR page |
#### Bearer-token API POSTs (CSRF EXEMPT — not browser-initiated)
| # | Method | Path | Notes |
|---|--------|------|-------|
| — | POST | `/api/selfupdate/check` | Accepts Bearer token (hub/scripts) |
| — | POST | `/api/selfupdate/update` | Accepts Bearer token (hub/scripts) |
| — | POST | `/api/config/apply` | Accepts Bearer token (hub push) |
These three endpoints accept **both** session auth and Bearer auth. When accessed with a Bearer token, CSRF is not needed. When accessed via session cookie from the UI, they should require CSRF. The middleware handles this: if `Authorization: Bearer` header is present and valid, skip CSRF check.
#### GET-only / No-auth endpoints (no CSRF needed)
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/health` | No auth, read-only |
| GET | `/api/stacks`, `/api/stacks/{name}`, etc. | Read-only |
| GET | `/api/backup/status`, `/api/backup/snapshots` | Read-only |
| GET | `/api/metrics/*`, `/api/system/info` | Read-only |
| GET | `/api/storage/*` (browse, status) | Read-only |
| GET | `/api/selfupdate/status` | Read-only |
| GET | `/api/config`, `/api/config/hash` | Read-only |
| GET | `/api/assets/status` | Read-only |
---
### 1.1 Add CSRF token to session struct
**File: `controller/internal/web/auth.go`**
Change the session struct to include a CSRF token:
```go
type session struct {
expiresAt time.Time
csrfToken string
}
```
Update `createSession()` to generate a CSRF token alongside the session token:
```go
func (s *Server) createSession() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
token := hex.EncodeToString(b)
csrfB := make([]byte, 32)
_, _ = rand.Read(csrfB)
csrfToken := hex.EncodeToString(csrfB)
s.sessionsMu.Lock()
s.sessions[token] = &session{
expiresAt: time.Now().Add(sessionMaxAge),
csrfToken: csrfToken,
}
s.sessionsMu.Unlock()
return token
}
```
Add a method to retrieve the CSRF token for a session:
```go
// csrfTokenForSession returns the CSRF token for the given session cookie value.
// Returns "" if the session is invalid or expired.
func (s *Server) csrfTokenForSession(sessionToken string) string {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
sess, ok := s.sessions[sessionToken]
if !ok || time.Now().After(sess.expiresAt) {
return ""
}
return sess.csrfToken
}
```
### 1.2 Create CSRF middleware
**File: `controller/internal/web/csrf.go`** (new file)
```go
package web
import (
"crypto/subtle"
"fmt"
"html/template"
"net/http"
"strings"
)
const csrfFormField = "_csrf"
const csrfHeaderName = "X-CSRF-Token"
// csrfProtect validates CSRF tokens on unsafe HTTP methods (POST, PUT, DELETE, PATCH).
// Safe methods (GET, HEAD, OPTIONS) pass through — the token is made available
// to templates via the Server.csrfToken() helper.
//
// Exempt: requests with a valid Authorization: Bearer header (API key auth).
func (s *Server) csrfProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods: no CSRF check needed
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
// Skip CSRF if auth is disabled (no password set = open access)
if !s.authEnabled() {
next.ServeHTTP(w, r)
return
}
// Skip CSRF for Bearer-token authenticated requests.
// These endpoints also accept session auth, but when a Bearer token
// is present, the request is from a script/hub, not a browser.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
next.ServeHTTP(w, r)
return
}
// Get the session's CSRF token
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
s.csrfReject(w, r, "no session")
return
}
expected := s.csrfTokenForSession(cookie.Value)
if expected == "" {
s.csrfReject(w, r, "invalid session")
return
}
// Check form field first, then header (for fetch/AJAX)
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get(csrfHeaderName)
}
if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 {
s.csrfReject(w, r, "token mismatch")
return
}
next.ServeHTTP(w, r)
})
}
// csrfReject sends a 403 response. For API requests returns JSON, for web requests returns HTML.
func (s *Server) csrfReject(w http.ResponseWriter, r *http.Request, reason string) {
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"ok":false,"error":"CSRF token missing or invalid"}`)
return
}
http.Error(w, "CSRF token missing or invalid. Please reload the page and try again.", http.StatusForbidden)
}
// csrfToken returns the CSRF token for the current request's session.
// Call from handlers to embed in templates or return to JS.
func (s *Server) csrfToken(r *http.Request) string {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return ""
}
return s.csrfTokenForSession(cookie.Value)
}
// csrfField returns an HTML hidden input for embedding in forms.
func (s *Server) csrfField(r *http.Request) template.HTML {
token := s.csrfToken(r)
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + template.HTMLEscapeString(token) + `">`)
}
```
### 1.3 Wire CSRF middleware into the server
**File: `controller/internal/web/server.go`**
The controller uses a custom router in `ServeHTTP`. The CSRF middleware must wrap the handler AFTER auth but protect all POST routes. Find where `RequireAuth` wraps the server's handler and add `csrfProtect` inside it.
In `server.go`, locate the HTTP server setup. The pattern is typically:
```go
handler := s.RequireAuth(s)
```
Change to:
```go
handler := s.RequireAuth(s.csrfProtect(s))
```
This means: RequireAuth runs first (verifies session), then csrfProtect runs (verifies CSRF token on POST), then the actual handler runs.
**Important:** Check the exact wiring in `server.go`. The auth middleware likely wraps the mux in `Start()` or `ListenAndServe()`. Find that location and insert `csrfProtect` between auth and handler. If the `RequireAuth` is applied in `main.go`, adjust there instead.
### 1.4 Add CSRF token to all template data
**File: `controller/internal/web/handlers.go`** (and `storage_handlers.go`, `handler_restore.go`)
Every handler that renders an HTML page must include the CSRF token in its template data. The recommended approach:
**Add `CSRFField` and `CSRFToken` to every template data map.** In each handler that renders a page, add these two fields:
```go
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
```
Alternatively, create an `executeTemplate` wrapper method that auto-injects these:
```go
// executeTemplate renders a template with per-request CSRF data injected.
func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
```
Then replace all `s.tmpl.ExecuteTemplate(w, "templateName", data)` calls with `s.executeTemplate(w, r, "templateName", data)`.
**Handlers that render pages (all need CSRFField/CSRFToken):**
- `handleDashboard` → dashboard template
- `handleStacks` → stacks template (if separate from dashboard)
- `handleDeploy` → deploy template
- `handleBackups` → backups template
- `handleMonitoring` → monitoring template
- `handleSettings` → settings template
- `handleAppInfo` → app_info template
- `handleLogs` → logs template
- `handleMigrate` → migrate template
- `handleMigrateDrive` → migrate_drive template
- `handleStorageInit` → init_disk template
- `handleStorageAttach` → attach_disk template
- `handleRestorePage` → restore template
- `renderLogin` → login template (**no CSRF needed** — no session yet)
### 1.5 Add hidden field to all HTML form templates
**File: `controller/internal/web/templates/*.html`**
For every `<form method="POST" ...>` in every template, add the CSRF hidden field right after the `<form>` tag:
```html
<form method="POST" action="/settings/password">
{{.CSRFField}}
<!-- existing form fields -->
</form>
```
Templates to update (search for `method="POST"` or `method="post"` in all template files):
| # | Template | Form action(s) |
|---|----------|----------------|
| 1 | `settings.html` | `/settings/password`, `/settings/notifications`, `/settings/notifications/test`, `/settings/storage/add`, `/settings/storage/remove`, `/settings/storage/default`, `/settings/storage/schedulable`, `/settings/storage/label` |
| 2 | `deploy.html` | Cross-backup form (if any form POST exists) |
| 3 | `backups.html` | `/backup/restore` form, cross-backup save forms |
| 4 | `init_disk.html` | Storage init wizard (if form-based) |
| 5 | `attach_disk.html` | Attach wizard (if form-based) |
| 6 | `restore.html` | DR restore (if form-based) |
**Note:** Many operations on these pages use JavaScript `fetch()` instead of HTML form submissions. For those, see section 1.6.
### 1.6 Add CSRF token to JavaScript fetch() calls
For all JavaScript code that makes POST/DELETE requests via `fetch()`, include the CSRF token in the `X-CSRF-Token` header.
**Step A: Embed the token in a meta tag in the base layout.**
In the template that defines the `<head>` section (likely within `{{define "head"}}` or the shared HTML header), add:
```html
<meta name="csrf-token" content="{{.CSRFToken}}">
```
**Step B: Create a helper function in the shared JS.**
At the top of the `<script>` block in the layout or in each template, add a reusable helper:
```javascript
function csrfHeaders() {
var el = document.querySelector('meta[name="csrf-token"]');
return el ? {'X-CSRF-Token': el.content} : {};
}
```
**Step C: Update every `fetch()` call to include CSRF headers.**
Example — before:
```javascript
fetch('/api/stacks/' + name + '/start', {method: 'POST'})
```
After:
```javascript
fetch('/api/stacks/' + name + '/start', {method: 'POST', headers: csrfHeaders()})
```
For fetch calls that already have headers (e.g., `Content-Type: application/json`), merge:
```javascript
fetch('/api/stacks/' + name + '/deploy', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify(data)
})
```
**Comprehensive list of templates with fetch() POST calls to update:**
Search all `.html` template files for `fetch(` with `method:` containing `POST` or `DELETE`:
| Template | fetch() calls to update |
|----------|------------------------|
| `dashboard.html` | Stack start/stop/restart/update buttons |
| `deploy.html` | Deploy, optional-config, cross-backup save/run, stale-cleanup |
| `stacks.html` | (if any action buttons) |
| `backups.html` | Backup run, cross-drive run-all, cross-backup run |
| `settings.html` | Storage disconnect/reconnect/restart-apps, self-update check/update, storage label/schedulable (if AJAX) |
| `init_disk.html` | Storage scan, init, poll status |
| `attach_disk.html` | Mount-raw, mkdir, attach, cancel, poll status |
| `migrate.html` | Migrate start, poll status |
| `migrate_drive.html` | Drive migration start, poll status |
| `app_info.html` | Optional config save |
| `restore.html` | Restore all, skip |
### 1.7 Files changed summary (Controller)
| # | File | Action | Description |
|---|------|--------|-------------|
| 1 | `internal/web/auth.go` | Edit | Add `csrfToken` field to session struct, generate in `createSession()`, add `csrfTokenForSession()` |
| 2 | `internal/web/csrf.go` | **New** | CSRF middleware, helpers (`csrfProtect`, `csrfReject`, `csrfToken`, `csrfField`) |
| 3 | `internal/web/server.go` | Edit | Wire `csrfProtect` middleware after `RequireAuth`, optionally add `executeTemplate` wrapper |
| 4 | `internal/web/handlers.go` | Edit | Pass CSRFField/CSRFToken to every page template render |
| 5 | `internal/web/storage_handlers.go` | Edit | Pass CSRFField/CSRFToken to storage page renders |
| 6 | `internal/web/handler_restore.go` | Edit | Pass CSRFField/CSRFToken to restore page render |
| 7 | `internal/web/templates/settings.html` | Edit | Add `{{.CSRFField}}` to all `<form>` tags + CSRF meta tag + update JS |
| 8 | `internal/web/templates/deploy.html` | Edit | CSRF meta tag + update JS fetch calls |
| 9 | `internal/web/templates/backups.html` | Edit | Add `{{.CSRFField}}` to restore form + CSRF meta tag + update JS |
| 10 | `internal/web/templates/dashboard.html` | Edit | CSRF meta tag + update JS fetch calls |
| 11 | `internal/web/templates/init_disk.html` | Edit | CSRF meta tag + update JS fetch calls |
| 12 | `internal/web/templates/attach_disk.html` | Edit | CSRF meta tag + update JS fetch calls |
| 13 | `internal/web/templates/migrate.html` | Edit | CSRF meta tag + update JS fetch calls |
| 14 | `internal/web/templates/migrate_drive.html` | Edit | CSRF meta tag + update JS fetch calls |
| 15 | `internal/web/templates/app_info.html` | Edit | CSRF meta tag + update JS fetch calls |
| 16 | `internal/web/templates/restore.html` | Edit | CSRF meta tag + update JS fetch calls |
| 17 | `internal/web/templates/monitoring.html` | Edit | CSRF meta tag (if any POST actions exist) |
| 18 | `cmd/controller/main.go` | Edit | Bump Version to "0.23.0" |
---
## Part 2: Hub CSRF (v0.3.8)
### Endpoint Map: All State-Changing (POST) Endpoints
#### Web Form/JS POSTs (need CSRF — session-authenticated)
| # | Method | Path | Handler | Type |
|---|--------|------|---------|------|
| 1 | POST | `/configs/new` | `handleConfigCreate` | Form submit |
| 2 | POST | `/configs/{id}/edit` | `handleConfigUpdate` | Form submit |
| 3 | POST | `/configs/{id}/delete` | `handleConfigDelete` | JS fetch |
| 4 | POST | `/configs/{id}/regen-password` | `handleConfigRegenPassword` | JS fetch |
| 5 | POST | `/customers/{id}/trigger-update` | `handleTriggerUpdate` | JS fetch |
| 6 | POST | `/customers/{id}/block` | `handleBlockCustomer` | JS fetch |
| 7 | POST | `/customers/{id}/unblock` | `handleUnblockCustomer` | JS fetch |
| 8 | POST | `/customers/{id}/push-config` | `handlePushConfig` | JS fetch |
| 9 | POST | `/customers/{id}/pull-config` | `handlePullConfig` | JS fetch |
| 10 | POST | `/customers/{id}/create-config` | `handleCreateConfigFromReport` | JS fetch |
#### API POSTs (CSRF EXEMPT — Bearer-token authenticated, not browser-initiated)
| # | Method | Path | Notes |
|---|--------|------|-------|
| — | POST | `/api/v1/report` | Controller pushes report (Bearer) |
| — | POST | `/api/v1/event` | Controller pushes event (Bearer) |
| — | POST | `/api/v1/notify` | Legacy notification (Bearer) |
| — | POST | `/api/v1/preferences` | Preference sync (Bearer) |
| — | POST | `/api/v1/infra-backup` | Infra backup push (Bearer) |
These all live under `/api/v1/` and use Bearer token auth exclusively. The CSRF middleware should skip all paths starting with `/api/v1/`.
---
### 2.1 Fix hub session model (PREREQUISITE)
**File: `hub/internal/web/server.go`**
The hub currently uses `hub_session=authenticated` as the cookie value — a literal string, not a random token. This must be fixed before CSRF can work (CSRF tokens must be tied to unpredictable sessions).
**A) Add session storage to the Server struct:**
```go
type Server struct {
// ... existing fields ...
sessions map[string]*hubSession
sessionsMu sync.RWMutex
}
type hubSession struct {
expiresAt time.Time
csrfToken string
}
```
Initialize in `New()`:
```go
sessions: make(map[string]*hubSession),
```
**B) Update `handleLogin` to create random session tokens:**
```go
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
password := r.FormValue("password")
if bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
// Generate random session token
b := make([]byte, 32)
_, _ = rand.Read(b)
sessionToken := hex.EncodeToString(b)
// Generate CSRF token
cb := make([]byte, 32)
_, _ = rand.Read(cb)
csrfToken := hex.EncodeToString(cb)
s.sessionsMu.Lock()
s.sessions[sessionToken] = &hubSession{
expiresAt: time.Now().Add(7 * 24 * time.Hour),
csrfToken: csrfToken,
}
s.sessionsMu.Unlock()
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: "hub_session",
Value: sessionToken,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure,
MaxAge: 86400 * 7,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
// Render login page (keep existing minimal HTML or use template)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></html>`))
}
```
**C) Update `RequireAuth` to validate random tokens:**
Replace the cookie check:
```go
// OLD:
if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" {
// NEW:
if cookie, err := r.Cookie("hub_session"); err == nil {
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if ok && time.Now().Before(sess.expiresAt) {
next.ServeHTTP(w, r)
return
}
}
```
**D) Add session cleanup goroutine** (start from `main.go`):
```go
func (s *Server) CleanupSessions(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sessionsMu.Lock()
now := time.Now()
for t, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, t)
}
}
s.sessionsMu.Unlock()
}
}
}
```
Start it in `main.go`: `go webServer.CleanupSessions(ctx)`
**E) Add imports** to `server.go`: `"crypto/rand"`, `"encoding/hex"`, `"sync"`
### 2.2 Add CSRF validation to hub
**File: `hub/internal/web/server.go`**
The simplest approach for the hub is to add CSRF validation directly in `ServeHTTP` at the top, before the routing switch. This avoids middleware composition complexity:
```go
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// CSRF protection for all POST/PUT/DELETE requests (web routes only)
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
if path != "/login" && s.passwordHash != "" {
if !s.validateCSRF(r) {
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr)
http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden)
return
}
}
}
// ... existing switch/case routing ...
}
```
Add a `validateCSRF` helper method:
```go
func (s *Server) validateCSRF(r *http.Request) bool {
// If no session cookie present, this is a Basic Auth or non-browser request — skip CSRF
cookie, err := r.Cookie("hub_session")
if err != nil {
return true // No session cookie = not a browser CSRF attack
}
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if !ok {
return false
}
submitted := r.FormValue("_csrf")
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
return submitted != "" && subtle.ConstantTimeCompare([]byte(submitted), []byte(sess.csrfToken)) == 1
}
```
Add helper methods for templates:
```go
// csrfToken returns the CSRF token for the current session.
func (s *Server) csrfToken(r *http.Request) string {
cookie, err := r.Cookie("hub_session")
if err != nil {
return ""
}
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if !ok {
return ""
}
return sess.csrfToken
}
// csrfField returns an HTML hidden input for forms.
func (s *Server) csrfField(r *http.Request) template.HTML {
tok := s.csrfToken(r)
return template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(tok) + `">`)
}
```
Add import: `"crypto/subtle"`
### 2.3 Add CSRF token to hub templates
**File: `hub/internal/web/templates/*.html`**
**A) Config form (`config_form.html`)** — used for both create and edit:
Add `{{.CSRFField}}` after each `<form>` opening tag.
**B) Customer unified page (`customer_unified.html`)** — has JS fetch() for trigger-update, block, unblock, push-config, pull-config, create-config:
Add CSRF meta tag in head:
```html
<meta name="csrf-token" content="{{.CSRFToken}}">
```
Update all `fetch()` calls to include `X-CSRF-Token` header. Add helper:
```javascript
function csrfHeaders() {
var el = document.querySelector('meta[name="csrf-token"]');
return el ? {'X-CSRF-Token': el.content} : {};
}
```
**C) Configs list (`configs.html`)** — has delete button with JS fetch():
Add CSRF meta tag + update delete/regen-password fetch calls.
**D) Dashboard (`dashboard.html`)** — likely read-only, but check for any POST actions. Add meta tag if needed.
**Templates to update:**
| # | Template | Changes |
|---|----------|---------|
| 1 | `config_form.html` | Add `{{.CSRFField}}` inside `<form>` |
| 2 | `customer_unified.html` | Add meta tag + update all fetch() POST calls |
| 3 | `configs.html` | Add meta tag + update delete/regen-password fetch() calls |
| 4 | `dashboard.html` | Check for POST actions, add meta tag if needed |
**E) Update all handler functions** to pass `CSRFField` and `CSRFToken` in template data:
For every handler that renders a template, add to the data struct/map:
```go
CSRFField: s.csrfField(r),
CSRFToken: s.csrfToken(r),
```
Hub handlers that render templates:
- `handleDashboard` — pass CSRFToken (if any POST actions on page)
- `handleCustomerUnified` — pass CSRFField + CSRFToken
- `handleConfigList` — pass CSRFToken
- `handleConfigNewForm` — pass CSRFField + CSRFToken
- `handleConfigEditForm` — pass CSRFField + CSRFToken
### 2.4 Hub Basic Auth note
The hub allows HTTP Basic Auth as an alternative to session cookies. The `validateCSRF` method above handles this correctly: if no session cookie is present (pure Basic Auth request), CSRF validation is skipped. This is safe because:
1. Cross-site forms cannot inject Basic Auth headers
2. Browser-cached Basic Auth is only auto-sent to the same origin
3. The SameSite=Lax cookie prevents cross-site cookie-based attacks
### 2.5 Files changed summary (Hub)
| # | File | Action | Description |
|---|------|--------|-------------|
| 1 | `internal/web/server.go` | Edit | Add session map + mutex + hubSession struct, random session tokens, SameSite=Lax, Secure flag, session cleanup, CSRF validation in ServeHTTP, csrfToken/csrfField/validateCSRF helpers |
| 2 | `internal/web/configs.go` | Edit | Pass CSRFField/CSRFToken to template data in all config handlers |
| 3 | `internal/web/templates/config_form.html` | Edit | Add `{{.CSRFField}}` to form |
| 4 | `internal/web/templates/customer_unified.html` | Edit | Add CSRF meta tag + update all JS fetch() calls |
| 5 | `internal/web/templates/configs.html` | Edit | Add CSRF meta tag + update JS fetch() calls |
| 6 | `internal/web/templates/dashboard.html` | Edit | Add CSRF meta tag (if any POST actions) |
| 7 | `cmd/hub/main.go` | Edit | Start session cleanup goroutine |
---
## Implementation Order
1. **Controller first** (v0.23.0) — larger surface area, more endpoints to protect
1. `auth.go` — Add csrfToken to session struct
2. `csrf.go` — New file with CSRF middleware + helpers
3. `server.go` — Wire middleware, add executeTemplate or inject CSRFField/CSRFToken
4. `handlers.go` — Pass CSRF data to all page renders
5. `storage_handlers.go` — Pass CSRF data to storage page renders
6. `handler_restore.go` — Pass CSRF data to restore page render
7. Templates — Add `{{.CSRFField}}` to forms + meta tags + update JS
8. Test: attempt POST without token → 403, with token → success
2. **Hub second** (v0.3.8) — fewer endpoints, session model fix is critical
1. `server.go` — Random session tokens, SameSite, Secure, cleanup
2. CSRF validation + helpers in server.go
3. `configs.go` — Pass CSRF data to template renders
4. Templates — Add hidden fields + meta tags + update JS
5. `main.go` — Start session cleanup goroutine
6. Test: attempt POST without token → 403, with token → success
---
## Testing Checklist
### Controller
- [ ] Login still works (no CSRF on login — no session yet)
- [ ] All form submissions work with CSRF token (settings, notifications, storage, backup restore)
- [ ] All JS-driven actions work (deploy, start/stop/restart, update, remove, delete, sync, backup, storage wizards, migration)
- [ ] Bearer-token requests still work without CSRF (selfupdate, config/apply from hub)
- [ ] Cross-site POST without token returns 403
- [ ] Cross-site fetch() without X-CSRF-Token returns 403
- [ ] After session expires and re-login, new CSRF token works
- [ ] Open-access mode (no password) still works — CSRF skipped
### Hub
- [ ] Login creates random session token (not literal "authenticated")
- [ ] Session cookie has SameSite=Lax + Secure (when TLS)
- [ ] All form submissions work (config create/edit)
- [ ] All JS-driven actions work (delete, regen-password, trigger-update, block/unblock, push/pull config)
- [ ] API endpoints still work with Bearer token (report push, event push, etc.)
- [ ] Cross-site POST without token returns 403
- [ ] Session cleanup removes expired sessions
- [ ] Basic Auth requests (no session cookie) bypass CSRF correctly
---
## Build & Deploy
### Controller (v0.23.0)
```bash
SSH=/c/Windows/System32/OpenSSH/ssh.exe
# 1. Commit and push
cd /e/git/deploy-felhom-compose
git add -A && git commit -m "feat: CSRF protection on all POST endpoints (v0.23.0)" && git push
# 2. Build
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && git -C ~/git/deploy-felhom-compose pull && ./build.sh v0.23.0 --push"
# 3. Deploy
$SSH kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller:v0.23.0 && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:v0.23.0|' docker-compose.yml && sudo docker compose up -d"
# 4. Verify
$SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
```
### Hub (v0.3.8)
```bash
# 1. Commit and push
cd /e/git/felhom.eu
git add -A && git commit -m "feat: CSRF protection + random session tokens (v0.3.8)" && git push
# 2. Build
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-hub && git checkout -- hub/go.mod && git pull && cd ~/build/felhom-hub && ./build.sh v0.3.8 --push"
# 3. Deploy via ArgoCD — update manifests/hub.yaml image tag to v0.3.8, then:
$SSH kisfenyo@192.168.0.180 "cd ~/git/felhom.eu && git pull && kubectl apply -f manifests/hub.yaml"
# 4. Verify
$SSH kisfenyo@192.168.0.180 "kubectl get pods -n felhom-system -l app=hub && kubectl logs -n felhom-system -l app=hub --tail 10"
```
**Deploy order:** Either order works. Both components are backward-compatible — CSRF tokens are only checked on the component's own forms/JS, not cross-component communication (which uses Bearer tokens).
+28 -3
View File
@@ -757,6 +757,30 @@ self_update:
- Session cleanup every 15 minutes
- All sessions invalidated on password change
- Conditional logout link (hidden when auth is disabled)
- Each session stores a dedicated CSRF token (separate 32-byte random value) alongside the session token
#### CSRF Protection (`internal/web/csrf.go`)
Synchronizer-token CSRF protection on all browser-facing state-mutating endpoints.
**How it works:**
- `CsrfProtect` middleware wraps all route handlers in `main.go`
- Safe methods (GET, HEAD, OPTIONS) pass through without validation
- For POST/DELETE/PATCH: reads token from `_csrf` form field or `X-CSRF-Token` request header; constant-time compares against the session's stored CSRF token
- On rejection: JSON `{"ok":false,"error":"CSRF token missing or invalid"}` for `/api/` paths; HTTP 403 text page for UI routes
- Logs: `[WARN] CSRF rejected: METHOD /path from addr (reason)`
**Exempt paths (no CSRF check):**
- Requests with `Authorization: Bearer ...` header — hub→controller API calls (selfupdate, config/apply). Browsers cannot auto-send Bearer headers, so cross-site requests are impossible on these endpoints.
- Auth-disabled mode (`authEnabled() == false`) — CSRF is meaningless when there is no session.
**Token delivery to templates:**
- `executeTemplate(w, r, name, data)` wrapper in `server.go` auto-injects `CSRFField` (`template.HTML` hidden `<input>`) and `CSRFToken` (raw string) into every page's data map
- `layout.html` emits `<meta name="csrf-token" content="{{.CSRFToken}}">` and defines `csrfHeaders()` JS function in `<head>` (before page scripts)
- Forms: `{{.CSRFField}}` (or `{{$.CSRFField}}` inside `{{range}}` loops — outer scope required)
- JS `fetch()` calls: `headers: csrfHeaders()` — returns `{'X-CSRF-Token': metaContent}`
- Dynamically-created JS forms: read token from `document.querySelector('meta[name="csrf-token"]').content`
- `navigator.sendBeacon()` replaced with `fetch(..., {keepalive: true})` where used — `sendBeacon` cannot send custom headers
#### Settings Persistence (`internal/settings/settings.go`)
@@ -1033,8 +1057,9 @@ controller/
│ │ └── templates/ # 7 wizard HTML templates (Hungarian)
│ ├── recovery/info.go # Recovery info file generator (recovery-info.txt)
│ └── web/
│ ├── server.go # HTTP server, routing, static files
│ ├── auth.go # Session auth, login/logout, session cleanup
│ ├── server.go # HTTP server, routing, static files, executeTemplate wrapper
│ ├── auth.go # Session auth + per-session CSRF token, login/logout, session cleanup
│ ├── csrf.go # CsrfProtect middleware, csrfToken/csrfField helpers
│ ├── handlers.go # Page handlers (dashboard, stacks, deploy, backups, etc.)
│ ├── handler_restore.go # DR: restore page handler + APIs (scan, restore all, skip)
│ ├── storage_handlers.go # Storage API handlers (scan, format, attach, migrate, cleanup, disconnect/reconnect)
@@ -1329,7 +1354,7 @@ See `docker-compose.yml` for the full volume configuration.
- [ ] Update classification and auto-apply (optional/required/security markers)
- [ ] Docker volume backup (`/var/lib/docker/volumes:ro`)
- [ ] Raspberry Pi testing (pi-customer-1)
- [ ] CSRF protection on POST endpoints
- [x] CSRF protection on POST endpoints (v0.23.0)
- [ ] Login rate limiting
---
+6 -5
View File
@@ -640,15 +640,16 @@ func main() {
// API routes (no auth for health endpoint, auth for everything else)
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
// Storage API routes handled by web server (longer prefix takes precedence over /api/)
mux.Handle("/api/storage/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeStorageAPI)))
mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
// Self-update API — accepts session auth OR hub API key (for external triggering)
mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, http.HandlerFunc(apiRouter.ServeHTTP)))
// CsrfProtect exempts Bearer-token requests automatically.
mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
// Config API — accepts session auth OR hub API key (for Hub config push)
mux.Handle("/api/config/", selfUpdateAuthMiddleware(cfg, webServer, http.HandlerFunc(apiRouter.ServeHTTP)))
mux.Handle("/api/", webServer.RequireAuth(http.HandlerFunc(apiRouter.ServeHTTP)))
mux.Handle("/api/config/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
mux.Handle("/api/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
// Web UI routes (auth required)
mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP)))
mux.Handle("/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeHTTP))))
// --- Start HTTP server ---
server := &http.Server{
+21 -1
View File
@@ -14,6 +14,7 @@ import (
type session struct {
expiresAt time.Time
csrfToken string
}
const (
@@ -141,13 +142,32 @@ func (s *Server) createSession() string {
_, _ = rand.Read(b)
token := hex.EncodeToString(b)
csrfB := make([]byte, 32)
_, _ = rand.Read(csrfB)
csrfToken := hex.EncodeToString(csrfB)
s.sessionsMu.Lock()
s.sessions[token] = &session{expiresAt: time.Now().Add(sessionMaxAge)}
s.sessions[token] = &session{
expiresAt: time.Now().Add(sessionMaxAge),
csrfToken: csrfToken,
}
s.sessionsMu.Unlock()
return token
}
// csrfTokenForSession returns the CSRF token for the given session cookie value.
// Returns "" if the session is invalid or expired.
func (s *Server) csrfTokenForSession(sessionToken string) string {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
sess, ok := s.sessions[sessionToken]
if !ok || time.Now().After(sess.expiresAt) {
return ""
}
return sess.csrfToken
}
func (s *Server) isValidSession(token string) bool {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
+95
View File
@@ -0,0 +1,95 @@
package web
import (
"crypto/subtle"
"fmt"
"html/template"
"net/http"
"strings"
)
const csrfFormField = "_csrf"
const csrfHeaderName = "X-CSRF-Token"
// CsrfProtect validates CSRF tokens on unsafe HTTP methods (POST, PUT, DELETE, PATCH).
// Safe methods (GET, HEAD, OPTIONS) pass through unchanged.
//
// Exempt cases:
// - Auth is disabled (no password configured)
// - Request has a valid Authorization: Bearer header (API key / hub auth)
func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods: no CSRF check needed
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
// Skip CSRF if auth is disabled (no password set = open access)
if !s.authEnabled() {
next.ServeHTTP(w, r)
return
}
// Skip CSRF for Bearer-token authenticated requests.
// These endpoints also accept session auth, but when a Bearer token
// is present, the request is from a script/hub, not a browser.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
next.ServeHTTP(w, r)
return
}
// Get the session's CSRF token
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
s.csrfReject(w, r, "no session cookie")
return
}
expected := s.csrfTokenForSession(cookie.Value)
if expected == "" {
s.csrfReject(w, r, "invalid or expired session")
return
}
// Check form field first, then header (for fetch/AJAX calls)
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get(csrfHeaderName)
}
if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 {
s.csrfReject(w, r, "token mismatch")
return
}
next.ServeHTTP(w, r)
})
}
// csrfReject sends a 403 response. Returns JSON for /api/ paths, plain text otherwise.
func (s *Server) csrfReject(w http.ResponseWriter, r *http.Request, reason string) {
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"ok":false,"error":"CSRF token missing or invalid"}`)
return
}
http.Error(w, "CSRF token missing or invalid. Please reload the page and try again.", http.StatusForbidden)
}
// csrfToken returns the CSRF token for the current request's session.
func (s *Server) csrfToken(r *http.Request) string {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return ""
}
return s.csrfTokenForSession(cookie.Value)
}
// csrfField returns an HTML hidden input for embedding in forms.
func (s *Server) csrfField(r *http.Request) template.HTML {
token := s.csrfToken(r)
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + template.HTMLEscapeString(token) + `">`)
}
+1 -1
View File
@@ -39,7 +39,7 @@ func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
"PlanStatus": status,
}
s.render(w, "restore", data)
s.executeTemplate(w, r, "restore", data)
}
// apiRestoreStatus returns the current restore plan status as JSON.
+29 -29
View File
@@ -174,7 +174,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
}
s.render(w, "dashboard", data)
s.executeTemplate(w, r, "dashboard", data)
}
func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) {
@@ -201,7 +201,7 @@ func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) {
}
data["StorageLabels"] = storageLabels
s.render(w, "stacks", data)
s.executeTemplate(w, r, "stacks", data)
}
func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) {
@@ -226,7 +226,7 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
data["Stack"] = stack
data["Logs"] = logs
s.render(w, "logs", data)
s.executeTemplate(w, r, "logs", data)
}
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
@@ -370,7 +370,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
data["FlashError"] = flashErr
}
s.render(w, "deploy", data)
s.executeTemplate(w, r, "deploy", data)
}
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
@@ -403,7 +403,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data["HasAppInfo"] = found.Meta.HasAppInfo()
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
s.render(w, "app_info", data)
s.executeTemplate(w, r, "app_info", data)
}
func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
@@ -453,7 +453,7 @@ func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
data["AllPingsConfigured"] = allConfigured
}
s.render(w, "monitoring", data)
s.executeTemplate(w, r, "monitoring", data)
}
// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder.
@@ -638,7 +638,7 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
data["Backup"] = nil
}
s.render(w, "backups", data)
s.executeTemplate(w, r, "backups", data)
}
// Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive.
@@ -1017,7 +1017,7 @@ func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
}
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
}
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
@@ -1032,21 +1032,21 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
data["PasswordError"] = "Hibás jelenlegi jelszó"
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
// Validate new password length
if len(newPassword) < 8 {
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
// Validate passwords match
if newPassword != confirmPassword {
data["PasswordError"] = "A két jelszó nem egyezik"
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1055,7 +1055,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
if err != nil {
s.logger.Printf("[ERROR] Failed to hash new password: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1063,7 +1063,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1126,7 +1126,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
s.logger.Printf("[ERROR] Failed to save notification prefs: %v", err)
data := s.settingsData()
data["NotificationError"] = "Hiba a beállítások mentésekor"
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1144,7 +1144,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
}
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
}
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
@@ -1152,7 +1152,7 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
if s.notifier == nil {
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1160,12 +1160,12 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
if err != nil {
s.logger.Printf("[ERROR] Test notification failed: %v", err)
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
data["NotificationSuccess"] = "Teszt email elküldve."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
}
// --- Storage path management handlers ---
@@ -1279,21 +1279,21 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
fi, err := os.Stat(path)
if err != nil || !fi.IsDir() {
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
// 2. Is mount point
if !system.IsMountPoint(path) {
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
// 3. Writable
if !system.IsWritable(path) {
data["StorageError"] = "Az útvonal nem írható."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1301,7 +1301,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
for _, existing := range s.settings.GetStoragePaths() {
if system.PathsOverlap(path, existing.Path) {
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
}
@@ -1322,7 +1322,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
if err := s.settings.AddStoragePath(sp); err != nil {
s.logger.Printf("[ERROR] Failed to add storage path: %v", err)
data["StorageError"] = "Hiba a mentés során."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1341,7 +1341,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
apps := s.appsUsingPath(path)
if len(apps) > 0 {
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1349,7 +1349,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == path && sp.IsDefault {
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
}
@@ -1357,13 +1357,13 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
// Check: last path
if len(s.settings.GetStoragePaths()) <= 1 {
data["StorageError"] = "Az utolsó adattároló nem törölhető."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
if err := s.settings.RemoveStoragePath(path); err != nil {
data["StorageError"] = "Hiba a törlés során."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1406,7 +1406,7 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
if label == "" || len(label) > 50 {
data := s.settingsData()
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
@@ -1414,7 +1414,7 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
s.logger.Printf("[ERROR] Failed to set storage label: %v", err)
data := s.settingsData()
data["StorageError"] = "Hiba a megnevezés mentésekor."
s.render(w, "settings", data)
s.executeTemplate(w, r, "settings", data)
return
}
+15
View File
@@ -265,6 +265,21 @@ func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
}
}
// executeTemplate renders a template with CSRF data auto-injected into the data map.
// Use this instead of render() for all authenticated page handlers.
func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// --- Static file / asset serving ---
func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
+4 -4
View File
@@ -144,7 +144,7 @@ func (s *Server) currentDiskJob() *activeDiskJob {
// storageInitHandler serves the storage init wizard page.
func (s *Server) storageInitHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("settings", "Meghajtó inicializálása")
s.render(w, "storage_init", data)
s.executeTemplate(w, r, "storage_init", data)
}
// storageAPIHandler is the main handler for /api/storage/* routes.
@@ -415,7 +415,7 @@ func (s *Server) migratePageHandler(w http.ResponseWriter, r *http.Request, stac
data["CurrentLabel"] = currentLabel
data["OtherPaths"] = otherPaths
data["DataSizeHuman"] = totalSizeHuman
s.render(w, "migrate", data)
s.executeTemplate(w, r, "migrate", data)
}
// storageMigrateAPIHandler handles POST /api/storage/migrate — starts migration job.
@@ -892,7 +892,7 @@ func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request)
// storageAttachHandler serves the attach wizard page.
func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("settings", "Meglévő meghajtó csatolása")
s.render(w, "storage_attach", data)
s.executeTemplate(w, r, "storage_attach", data)
}
// storageAttachMountRawHandler handles POST /api/storage/attach/mount-raw.
@@ -1362,7 +1362,7 @@ func (s *Server) migrateDrivePageHandler(w http.ResponseWriter, r *http.Request)
}
data["Tier2Impact"] = tier2Impact
s.render(w, "migrate_drive", data)
s.executeTemplate(w, r, "migrate_drive", data)
}
// driveMigrateAPIHandler handles POST /api/storage/migrate-drive — starts drive migration.
@@ -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) {