Files
pad/internal/server/middleware_csrf.go
T
xarmian e73196f590 fix(server): constant-time compare for CSRF token validation (TASK-659) (#187)
* 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.
2026-04-21 23:46:48 -04:00

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)
}