Files

113 lines
3.8 KiB
Go

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(`<input type="hidden" name="` + csrfFormField + `" value="` + escaped + `">`)
}