mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
460d213526
Exempt new sidecar endpoints (/admin/stripe-customer-id, /admin/user-by-customer) from RequireAuth and CSRF middleware. Fix OAuth unlink lockout guard that never triggered because PasswordHash is always non-empty. Return total count from admin user list for pagination support. Co-Authored-By: Claude <noreply@anthropic.com>
123 lines
3.5 KiB
Go
123 lines
3.5 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
csrfHeader = "X-CSRF-Token"
|
|
csrfTokenLen = 32 // 32 bytes = 64 hex chars
|
|
)
|
|
|
|
// CSRFProtect implements the double-submit cookie pattern for CSRF protection.
|
|
// It validates that state-changing requests (POST, PATCH, PUT, DELETE) from
|
|
// cookie-authenticated sessions include a matching CSRF token in both the
|
|
// cookie and the X-CSRF-Token header.
|
|
//
|
|
// Requests authenticated via Bearer tokens (API tokens / CLI) are exempt
|
|
// because they are not vulnerable to CSRF attacks — the browser never
|
|
// attaches Authorization headers automatically.
|
|
//
|
|
// Safe methods (GET, HEAD, OPTIONS) are always allowed through.
|
|
func (s *Server) CSRFProtect(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Safe methods are exempt
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Non-API paths are exempt (SPA static files, etc.)
|
|
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Auth endpoints that need to work before a CSRF token exists
|
|
// (login, register, bootstrap, password reset).
|
|
// The cloud plan endpoint is also exempt — the sidecar authenticates
|
|
// via cloud_secret in the body, not via cookies.
|
|
if strings.HasPrefix(r.URL.Path, "/api/v1/auth/") ||
|
|
r.URL.Path == "/api/v1/admin/plan" || r.URL.Path == "/api/v1/admin/stripe-customer-id" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Bearer token requests are not vulnerable to CSRF — skip
|
|
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// No users exist (fresh install) — skip CSRF
|
|
count, err := s.store.UserCount()
|
|
if err != nil || count == 0 {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Cookie-based session: require CSRF token
|
|
cookie, err := r.Cookie(csrfCookieName(s.secureCookies))
|
|
if err != nil || cookie.Value == "" {
|
|
writeError(w, http.StatusForbidden, "csrf_error", "Missing CSRF token")
|
|
return
|
|
}
|
|
|
|
headerToken := r.Header.Get(csrfHeader)
|
|
if headerToken == "" {
|
|
writeError(w, http.StatusForbidden, "csrf_error", "Missing CSRF header")
|
|
return
|
|
}
|
|
|
|
if cookie.Value != headerToken {
|
|
writeError(w, http.StatusForbidden, "csrf_error", "CSRF token mismatch")
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// setCSRFCookie writes a new CSRF token cookie. The cookie is NOT HttpOnly
|
|
// so that JavaScript can read it and send it back as a header.
|
|
func setCSRFCookie(w http.ResponseWriter, ttl int, secure bool) {
|
|
token := generateCSRFToken()
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: csrfCookieName(secure),
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: ttl,
|
|
HttpOnly: false, // Must be readable by JS
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// clearCSRFCookie removes the CSRF cookie (e.g. on logout).
|
|
// Must clear both prefixed and unprefixed names to handle upgrades cleanly.
|
|
func clearCSRFCookie(w http.ResponseWriter) {
|
|
for _, name := range []string{"pad_csrf", "__Host-pad_csrf"} {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: false,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
}
|
|
|
|
// generateCSRFToken returns a cryptographically random hex string.
|
|
func generateCSRFToken() string {
|
|
b := make([]byte, csrfTokenLen)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("csrf: failed to generate random token: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|