02650e3202
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>
856 lines
34 KiB
Markdown
856 lines
34 KiB
Markdown
# 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).
|