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 unchanged. // // Exempt cases: // - Auth is disabled (no password configured) // - Request has a valid Authorization: Bearer header (API key / hub 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. // Validate the token against the configured API key before skipping. if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { token := strings.TrimPrefix(auth, "Bearer ") apiKey := s.cfg.Hub.APIKey if apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) == 1 { next.ServeHTTP(w, r) return } // Invalid Bearer token — fall through to CSRF validation } // Get the session's CSRF token cookie, err := r.Cookie(sessionCookieName) if err != nil { s.csrfReject(w, r, "no session cookie") return } expected := s.csrfTokenForSession(cookie.Value) if expected == "" { s.csrfReject(w, r, "invalid or expired session") return } // Check form field first, then header (for fetch/AJAX calls) 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. Returns JSON for /api/ paths, plain text otherwise. 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. 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(``) }