// Package api - CSRF protection. // // CSRF only matters for credentials the browser attaches automatically. The SPA // authenticates with a bearer token it holds in localStorage and sets on each // request, and API clients send X-API-Key — neither is ambient, so neither is // forgeable cross-site, and both skip these checks. What the middleware guards // is cookie-authenticated mutation. // // Tokens are stateless and signed: . keyed by // the application secret. That means no server-side store to expire or // replicate, while a token still cannot be minted by an attacker who cannot // read the secret. package api import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "fmt" "net/http" "strings" ) // CSRF issues and validates CSRF tokens. type CSRF struct { secret []byte } // NewCSRF creates a CSRF issuer/validator keyed by the application secret. func NewCSRF(secret []byte) *CSRF { return &CSRF{secret: secret} } // IssueToken returns a fresh signed token. func (c *CSRF) IssueToken() (string, error) { nonce := make([]byte, 16) if _, err := rand.Read(nonce); err != nil { return "", fmt.Errorf("generating csrf nonce: %w", err) } n := base64.RawURLEncoding.EncodeToString(nonce) return n + "." + c.sign(n), nil } // ValidToken reports whether token was issued by this server. func (c *CSRF) ValidToken(token string) bool { nonce, sig, ok := strings.Cut(token, ".") if !ok || nonce == "" || sig == "" { return false } return subtle.ConstantTimeCompare([]byte(sig), []byte(c.sign(nonce))) == 1 } func (c *CSRF) sign(nonce string) string { mac := hmac.New(sha256.New, c.secret) mac.Write([]byte(nonce)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } // Handler serves GET /api/v1/auth/csrf. func (c *CSRF) Handler(w http.ResponseWriter, r *http.Request) { token, err := c.IssueToken() if err != nil { WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Could not issue a CSRF token") return } WriteJSON(w, http.StatusOK, map[string]string{"token": token}) } // Middleware rejects cookie-authenticated mutating requests that do not carry a // valid X-CSRF-Token. Safe methods pass, and so do requests that authenticate // with an explicit Authorization/X-API-Key header, which CSRF cannot forge. func (c *CSRF) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if isReadMethod(r.Method) { next.ServeHTTP(w, r) return } // Explicit credentials are not attached by the browser on a cross-site // request, so these are not CSRF-reachable. if r.Header.Get("X-API-Key") != "" || strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { next.ServeHTTP(w, r) return } // No ambient credential either: nothing to protect. Let it through so // the auth middleware produces the 401. if _, err := r.Cookie(DocsCookieName); err != nil { next.ServeHTTP(w, r) return } if !c.ValidToken(r.Header.Get("X-CSRF-Token")) { WriteError(w, http.StatusForbidden, ErrCodeForbidden, "A valid X-CSRF-Token header is required for cookie-authenticated requests (get one from GET /api/v1/auth/csrf)") return } next.ServeHTTP(w, r) }) }