// SPDX-License-Identifier: Apache-2.0 package server import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "html/template" "log" "net/http" "strings" "github.com/X4Applegate/caddyui/internal/auth" "github.com/X4Applegate/caddyui/internal/models" ) // CSRF protection for cookie-authenticated, state-changing requests. // // v2.29.0. Session cookies are SameSite=Lax, which already blocks the classic // cross-site form POST in current browsers, but that is a single layer resting // on a browser default. This adds a synchroniser token as defence in depth. // // The token is HMAC-SHA256(secret, sessionToken), so it needs no storage of // its own and is automatically bound to one session: logging out or logging in // again produces a new session token and therefore a new CSRF token. An // attacker cannot compute it without reading the victim's session cookie, // which is HttpOnly. // // Requests authenticated by bearer token are exempt. A browser never attaches // an Authorization header to a cross-site request on its own, so those calls // are not CSRF-reachable, and requiring a token would break API clients. const ( csrfFieldName = "csrf_token" csrfHeaderName = "X-CSRF-Token" settingCSRFKey = "csrf_secret" ) // csrfSecret returns the process-wide HMAC key, generating and persisting one // on first use. Kept in the settings table rather than derived from something // like the DB path so tokens stay valid across restarts — regenerating on boot // would invalidate every live session's token and 403 users mid-flow. func (s *Server) csrfSecret() []byte { s.csrfSecretOnce.Do(func() { if existing, err := models.GetSetting(s.DB, settingCSRFKey); err == nil { if raw, err := hex.DecodeString(strings.TrimSpace(existing)); err == nil && len(raw) == 32 { s.csrfSecretCache = raw return } } buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { // crypto/rand failing is not recoverable in any useful way here. // Panicking beats silently falling back to a predictable key that // would make every token forgeable. panic("csrf: generate secret: " + err.Error()) } if err := models.SetSetting(s.DB, settingCSRFKey, hex.EncodeToString(buf)); err != nil { // Persisting failed — keep the in-memory key so this process still // works. Tokens simply won't survive a restart. log.Printf("csrf: persist secret: %v", err) } s.csrfSecretCache = buf }) return s.csrfSecretCache } // csrfTokenFor derives the token for a session token. Returns "" for an empty // session so anonymous pages render no token rather than a constant one. func (s *Server) csrfTokenFor(sessionToken string) string { if sessionToken == "" { return "" } mac := hmac.New(sha256.New, s.csrfSecret()) mac.Write([]byte(sessionToken)) return hex.EncodeToString(mac.Sum(nil)) } // csrfTokenForRequest reads the session cookie and derives its token. func (s *Server) csrfTokenForRequest(r *http.Request) string { if r == nil { return "" } cookie, err := r.Cookie(auth.SessionCookie) if err != nil || cookie.Value == "" { return "" } return s.csrfTokenFor(cookie.Value) } // csrfField renders the hidden input that templates embed in every POST form. // Returns empty HTML when there is no session, so the login and setup pages // (which have no session to protect) render unchanged. func (s *Server) csrfField(r *http.Request) template.HTML { token := s.csrfTokenForRequest(r) if token == "" { return "" } // token is hex from HMAC output — no user input reaches it, so it cannot // break out of the attribute. return template.HTML(``) } // csrfInjectForms inserts the hidden token input immediately after the opening // tag of every POST form in a rendered page. // // Done here, on the rendered output, rather than by adding {{$.CSRFField}} to // each of the ~75 forms in web/templates. One choke point means a form added // later is protected automatically instead of silently 403-ing the first time // someone submits it, and it avoids the template-scoping trap where `.` inside // a {{range}} is not the root data. // // The scan is quote-aware so a '>' inside an attribute value (an inline // onclick, say) doesn't terminate the tag early. Template actions are already // evaluated by this point, so only quoting has to be handled. func csrfInjectForms(page, field []byte) []byte { if len(field) == 0 || len(page) == 0 { return page } var out []byte pos := 0 for { idx := indexFoldASCII(page[pos:], "' closing the tag that starts at // `start`, skipping quoted attribute values. Returns -1 if unterminated. func csrfTagEnd(page []byte, start int) int { var quote byte for i := start; i < len(page); i++ { c := page[i] if quote != 0 { if c == quote { quote = 0 } continue } switch c { case '"', '\'': quote = c case '>': return i + 1 } } return -1 } // indexFoldASCII is a case-insensitive bytes.Index for an ASCII needle. func indexFoldASCII(haystack []byte, needle string) int { n := len(needle) if n == 0 || len(haystack) < n { return -1 } for i := 0; i+n <= len(haystack); i++ { if equalFoldASCII(haystack[i:i+n], needle) { return i } } return -1 } func bytesContainsFold(haystack []byte, needle string) bool { return indexFoldASCII(haystack, needle) >= 0 } func equalFoldASCII(b []byte, s string) bool { if len(b) != len(s) { return false } for i := range b { x, y := b[i], s[i] if 'A' <= x && x <= 'Z' { x += 'a' - 'A' } if 'A' <= y && y <= 'Z' { y += 'a' - 'A' } if x != y { return false } } return true } // csrfSafeMethod reports whether a method is defined as safe and so needs no // token. HEAD and OPTIONS are included alongside GET per RFC 9110. func csrfSafeMethod(method string) bool { switch method { case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: return true } return false } // requireCSRF rejects state-changing requests that do not carry a token // matching the caller's session. // // Ordering matters: this must run *inside* requireAuth, because it relies on // requireAuth having already established how the request is authenticated. func (s *Server) requireCSRF(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if csrfSafeMethod(r.Method) { next.ServeHTTP(w, r) return } // Bearer-token clients are not CSRF-reachable — see the package note. if currentAPITokenScope(r) != "" { next.ServeHTTP(w, r) return } expected := s.csrfTokenForRequest(r) if expected == "" { // No session cookie: nothing to protect, and requireAuth has // already decided whether this request may proceed at all. next.ServeHTTP(w, r) return } if !csrfTokenMatches(expected, csrfTokenFromRequest(r)) { log.Printf("csrf: rejected %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) http.Error(w, "CSRF token missing or invalid — reload the page and try again.", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // csrfTokenFromRequest pulls the token from the form field or the header. The // header path is what the JSON/fetch callers use; see the fetch wrapper in // web/static/app.js. // // Reading the form value would normally consume the body, so this only parses // form-encoded content types. JSON handlers keep an intact body and are // expected to send the header instead. func csrfTokenFromRequest(r *http.Request) string { if v := strings.TrimSpace(r.Header.Get(csrfHeaderName)); v != "" { return v } ct := r.Header.Get("Content-Type") if strings.HasPrefix(ct, "application/x-www-form-urlencoded") || strings.HasPrefix(ct, "multipart/form-data") { // ParseForm caches its result on the request, so the downstream // handler's own ParseForm call is a no-op rather than a second read of // an already-drained body. if err := r.ParseForm(); err == nil { return strings.TrimSpace(r.PostFormValue(csrfFieldName)) } } return "" } // csrfTokenMatches compares in constant time. func csrfTokenMatches(expected, got string) bool { if expected == "" || got == "" { return false } return hmac.Equal([]byte(expected), []byte(got)) }