mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-20 17:43:26 +00:00
e73196f590
* fix(server): constant-time compare for CSRF token validation (TASK-659) The CSRF middleware compared the cookie and header tokens with Go's == operator, which short-circuits on the first byte mismatch. An attacker who can observe response timing can binary-search for the matching token prefix byte by byte — theoretically useful against a local attacker with precise timing, less so against remote attackers but still a hygiene fix. - middleware_csrf.go: switch to subtle.ConstantTimeCompare. Also explicitly check length equality first, because ConstantTimeCompare returns 0 for mismatched lengths and an earlier Go == check would leak a timing signal about "how many leading bytes matched before the length diverged." Existing CSRF tests (FreshInstallExempt, LoginSetsCSRFCookie, LogoutClearsCSRFCookie, AllMutationMethodsBlocked) continue to pass — the only semantic change is timing-safety on validation. Note on the task's HMAC binding suggestion: binding the CSRF token to the session via HMAC is tracked as a follow-up. It requires a stable server-side HMAC key (similar to the 2FA challenge secret), platform- settings persistence, and a session-cookie-dependent setCSRFCookie signature — larger change than this PR is scoped for. Parent: PLAN-643 (OSS Security Hardening). * fix(server): length-check CSRF as strings before allocating per Codex P2 Codex caught that converting both tokens to []byte up-front forces an allocation proportional to the attacker-controlled X-CSRF-Token header on every failing request — a mild DoS/GC-pressure vector. Compare string lengths first (no allocation), short-circuit on mismatch, and only convert to []byte when lengths match. The allocated path then runs subtle.ConstantTimeCompare for the timing-safe comparison. * fix(server): reject off-size CSRF tokens before allocating per Codex P2 Codex caught that the length-match check still allowed attacker- controlled equally-sized tokens of any size (up to MaxHeaderBytes) to trigger the []byte allocation pair. Since CSRF tokens are always csrfTokenLen*2 hex chars (64 bytes), we can safely reject any length that doesn't match the expected fixed size before allocating anything. - middleware_csrf.go: add expectedLen := csrfTokenLen * 2 (hex), reject any cookie/header whose length != expectedLen before converting to []byte. The subsequent subtle.ConstantTimeCompare then operates on fixed-size 64-byte copies. - middleware_csrf_test.go + handlers_auth_test.go: bump all test fixture tokens to 64 hex chars so the fixed-length validation accepts them. The test-only tokens are arbitrary hex (not generated by the real generator) — they just have to match the shape.
152 lines
5.0 KiB
Go
152 lines
5.0 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"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).
|
|
if strings.HasPrefix(r.URL.Path, "/api/v1/auth/") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Cloud sidecar calls bypass CSRF because they don't use cookie-
|
|
// based sessions; they authenticate via X-Cloud-Secret (or legacy
|
|
// ?cloud_secret). Path-gate this explicitly so a stray
|
|
// ?cloud_secret= on any other /api/ path (trivial in a cross-site
|
|
// form action) cannot be used to defeat CSRF elsewhere. Admin calls
|
|
// over cookie sessions to the same three endpoints fall through
|
|
// and still require a CSRF token — that is the entire point of
|
|
// narrowing from a path carve-out to a credential-plus-path check.
|
|
if isCloudAdminPath(r.URL.Path) && hasCloudSecretMarker(r) {
|
|
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
|
|
}
|
|
|
|
// CSRF tokens are fixed-size hex strings (csrfTokenLen bytes →
|
|
// csrfTokenLen*2 hex chars). Reject any token that doesn't
|
|
// match the expected length BEFORE allocating. Without this
|
|
// an attacker could flood with equally-sized cookie + header
|
|
// pairs (within the 64 KiB MaxHeaderBytes cap) and each
|
|
// failing request would allocate the []byte copies below
|
|
// proportional to the header size — cheap per request, but
|
|
// a noticeable GC cost under sustained load. Post-check,
|
|
// both values are bounded to csrfTokenLen*2 bytes so the
|
|
// allocation is a fixed, tiny cost.
|
|
const expectedLen = csrfTokenLen * 2 // hex encoding
|
|
if len(cookie.Value) != expectedLen || len(headerToken) != expectedLen {
|
|
writeError(w, http.StatusForbidden, "csrf_error", "CSRF token mismatch")
|
|
return
|
|
}
|
|
// subtle.ConstantTimeCompare evaluates the byte compare in time
|
|
// independent of where the first differing byte lives, removing
|
|
// the timing side-channel that the previous `!=` had.
|
|
if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(headerToken)) != 1 {
|
|
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)
|
|
}
|