package admin import ( "crypto/rand" "crypto/subtle" "encoding/base64" "fmt" "html/template" "log/slog" "net/http" "strings" ) const ( csrfHeaderName = "X-CSRF-Token" csrfFormField = "csrf_token" ) // generateCSRFToken returns a cryptographically random token. // 32 bytes (256 bits) base64url-encoded, matching the session token format. func generateCSRFToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generate csrf token: %w", err) } return base64.URLEncoding.EncodeToString(b), nil } // requireCSRF validates a per-session CSRF token on state-mutating requests. // Safe methods (GET/HEAD/OPTIONS) are unchecked; everything else must supply // the token via the X-CSRF-Token header (htmx path) or the csrf_token form // field on application/x-www-form-urlencoded bodies (plain-form path). // Multipart bodies are rejected unless the header is present — this avoids // consuming a multipart body in middleware and stepping on per-handler size // limits such as http.MaxBytesReader. // // Must run after requireOwner so a session is present on the request context. func (ui *AdminUI) requireCSRF(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: next.ServeHTTP(w, r) return } session := getSessionFromContext(r.Context()) if session == nil || session.CSRFToken == "" { slog.Warn("CSRF check failed: no session token", "path", r.URL.Path, "method", r.Method) csrfReject(w, r, "missing session") return } got := r.Header.Get(csrfHeaderName) if got == "" { contentType := r.Header.Get("Content-Type") // Split off any ;boundary=... suffix before comparing. if idx := strings.IndexByte(contentType, ';'); idx >= 0 { contentType = contentType[:idx] } contentType = strings.TrimSpace(strings.ToLower(contentType)) switch contentType { case "application/x-www-form-urlencoded": if err := r.ParseForm(); err == nil { got = r.PostFormValue(csrfFormField) } case "multipart/form-data": // Parse multipart with a small limit just to read the CSRF // field. 32 KB is enough for the token + file metadata without // loading uploaded file data into memory — Go stores the file // portion beyond maxMemory on disk. const csrfMultipartMaxMem = 32 << 10 // 32 KB if err := r.ParseMultipartForm(csrfMultipartMaxMem); err == nil { got = r.FormValue(csrfFormField) } } } if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 { slog.Warn("CSRF token mismatch", "path", r.URL.Path, "method", r.Method, "did", session.DID, "provided", got != "") csrfReject(w, r, "token mismatch") return } next.ServeHTTP(w, r) }) } // csrfReject returns a 403. For htmx requests it surfaces a toast via the // standard HX-Trigger channel so the page-level error handler can announce // the failure; for plain browsers it's a text response. func csrfReject(w http.ResponseWriter, r *http.Request, reason string) { const userMsg = "Session expired or CSRF token invalid — reload the page and try again." if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Trigger", `{"toast":{"message":"`+userMsg+`","type":"error"}}`) w.Header().Set("HX-Reswap", "none") w.WriteHeader(http.StatusForbidden) return } http.Error(w, "Forbidden: "+userMsg, http.StatusForbidden) } // csrfInputHTML returns a hidden form input carrying the CSRF token, safely // escaped for attribute context. func csrfInputHTML(token string) template.HTML { escaped := template.HTMLEscapeString(token) return template.HTML(``) }