// 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:], "