package webserver import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "fmt" "net/http" "strings" "sync" ) const ( csrfCookieName = "metacrypt_csrf" csrfFieldName = "csrf_token" csrfTokenLen = 32 ) // csrfProtect provides CSRF protection using the signed double-submit cookie // pattern. A random secret is generated at startup. CSRF tokens are an HMAC of // a random nonce, sent as both a cookie and a hidden form field. On POST the // middleware verifies that the form field matches the cookie's HMAC. type csrfProtect struct { secret []byte once sync.Once } func newCSRFProtect() *csrfProtect { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { panic("csrf: failed to generate secret: " + err.Error()) } return &csrfProtect{secret: secret} } // generateToken creates a new CSRF token: base64(nonce) + "." + base64(hmac(nonce)). func (c *csrfProtect) generateToken() (string, error) { nonce := make([]byte, csrfTokenLen) if _, err := rand.Read(nonce); err != nil { return "", fmt.Errorf("csrf: generate nonce: %w", err) } nonceB64 := base64.RawURLEncoding.EncodeToString(nonce) mac := hmac.New(sha256.New, c.secret) mac.Write(nonce) sigB64 := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return nonceB64 + "." + sigB64, nil } // validToken checks that a token has a valid HMAC signature. func (c *csrfProtect) validToken(token string) bool { parts := strings.SplitN(token, ".", 2) if len(parts) != 2 { return false } nonce, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil || len(nonce) != csrfTokenLen { return false } sig, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return false } mac := hmac.New(sha256.New, c.secret) mac.Write(nonce) return hmac.Equal(mac.Sum(nil), sig) } // setToken generates a new CSRF token, sets it as a cookie, and returns it // for embedding in a form. func (c *csrfProtect) setToken(w http.ResponseWriter) string { token, err := c.generateToken() if err != nil { return "" } http.SetCookie(w, &http.Cookie{ Name: csrfCookieName, Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) return token } // middleware returns an HTTP middleware that enforces CSRF validation on // mutation requests (POST, PUT, PATCH, DELETE). GET/HEAD/OPTIONS are passed // through. The HTMX hx-post for /v1/seal is excluded since it hits the API // server directly and uses token auth, not cookies. func (c *csrfProtect) middleware(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 } // Read token from form field (works for both regular forms and // multipart forms since ParseForm/ParseMultipartForm will have // been called or the field is available via FormValue). formToken := r.FormValue(csrfFieldName) // Read token from cookie. cookie, err := r.Cookie(csrfCookieName) if err != nil || cookie.Value == "" { http.Error(w, "CSRF validation failed", http.StatusForbidden) return } // Both tokens must be present, match each other, and be validly signed. if formToken == "" || formToken != cookie.Value || !c.validToken(formToken) { http.Error(w, "CSRF validation failed", http.StatusForbidden) return } next.ServeHTTP(w, r) }) }