Files
nezha_domains/cmd/dashboard/controller/csrf.go
T
naibaandcloudcode 05e14981b5 fix(security): verify transfer-token HMAC and sign CSRF double-submit cookie
- consumeTransferToken now recomputes and constant-time compares the
  entry's HMAC-SHA256; previously the signature was minted but never
  checked, so the documented "防篡改" guarantee was hollow and security
  rested solely on the sync.Map key's randomness.
- CSRF switches from a raw-random double-submit cookie to a signed token
  (nonce.HMAC-SHA256 keyed by JWTSecretKey). The middleware now also
  validates the signature, defeating sibling-subdomain cookie tossing
  where a naive header==cookie pair would otherwise pass.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 05:48:50 +00:00

145 lines
4.9 KiB
Go

package controller
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// issueCSRFToken mints a signed double-submit token (nonce.HMAC-SHA256 keyed
// by the JWT secret). Signing defeats sibling-subdomain cookie tossing: a
// naive double-submit trusts any header==cookie pair, but an injected cookie
// carries no valid HMAC and fails validateCSRFToken. Returns "" pre-init
// (no secret); callers treat that as "no cookie minted".
func issueCSRFToken() string {
secret := csrfSigningSecret()
if secret == "" {
return ""
}
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
nonce := hex.EncodeToString(b[:])
return nonce + "." + csrfSign(nonce, secret)
}
func csrfSigningSecret() string {
if singleton.Conf == nil {
return ""
}
return singleton.Conf.JWTSecretKey
}
func csrfSign(nonce, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(nonce))
return hex.EncodeToString(mac.Sum(nil))
}
// validateCSRFToken reports whether value is a well-formed nonce.signature
// pair whose signature verifies under the current server secret. Constant
// -time comparison guards against signature-probing side channels.
func validateCSRFToken(value string) bool {
secret := csrfSigningSecret()
if secret == "" || value == "" {
return false
}
idx := strings.LastIndex(value, ".")
if idx <= 0 || idx == len(value)-1 {
return false
}
nonce, sig := value[:idx], value[idx+1:]
return hmac.Equal([]byte(sig), []byte(csrfSign(nonce, secret)))
}
// setCSRFCookie issues a fresh signed CSRF token cookie. Called by login +
// refresh handlers so the frontend always has a paired value to mirror back
// into the X-CSRF-Token header. The cookie is intentionally HttpOnly=false —
// SPA JS must be able to read it. SameSite=Strict here (not Lax) because
// the cookie's sole purpose is the same-origin double-submit check and we
// don't want it leaking on cross-site GET navigation either.
func setCSRFCookie(c *gin.Context) {
token := issueCSRFToken()
if token == "" {
return
}
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(csrfCookieName, token, 0, "/", "", false, false)
}
const (
csrfCookieName = "nz-csrf"
csrfHeaderName = "X-CSRF-Token"
)
// csrfMiddleware enforces a double-submit-cookie CSRF gate on unsafe
// HTTP methods for cookie-authenticated requests.
//
// Why: SameSite=Lax on the nz-jwt cookie blocks the simplest cross-site
// form POST, but it does not stop same-site XSS-pivot CSRF, header method
// override, redirect-leaking auth helpers, or carefully chained sub-domain
// attacks. The double-submit pattern (server sets a JS-readable nz-csrf
// cookie, client mirrors the value into X-CSRF-Token) closes the gap
// without coupling auth state to a server-side session.
//
// Bypass conditions:
// - Safe methods (GET/HEAD/OPTIONS): no state mutation, no CSRF risk.
// - Bearer-token PAT requests (`Authorization: Bearer nzp_*`): stateless,
// no ambient cookie, so a CSRF attack cannot induce them.
//
// Reject conditions:
// - Missing or empty X-CSRF-Token header.
// - Missing or empty nz-csrf cookie.
// - Header value != cookie value.
// - Cookie value not signed by the server (validateCSRFToken fails).
//
// The middleware DOES NOT set the csrf cookie on its own — that is the
// JWT login / refresh handler's job, since those are the only places that
// know when to mint a fresh value. The pair just has to exist by the time
// any unsafe call reaches here.
func csrfMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
// Self-heal sessions that predate the CSRF cookie (or whose
// nz-csrf expired): seed a fresh value on a safe method so the
// double-submit pair exists before the next unsafe call. Safe
// methods mutate nothing, so minting here carries no CSRF risk.
if cookie, err := c.Cookie(csrfCookieName); err != nil || cookie == "" {
setCSRFCookie(c)
}
c.Next()
return
}
// A PAT request carries no ambient cookie, so CSRF cannot induce it.
// The exemption must check the authenticated PAT identity resolved by
// apiTokenAuthMiddleware, not a forgeable Authorization header value.
if APITokenFromContext(c) != nil {
c.Next()
return
}
header := c.GetHeader(csrfHeaderName)
cookie, err := c.Cookie(csrfCookieName)
// Both halves must be present, mirror each other, AND carry a valid
// server signature. The signature check is what stops a cookie-tossed
// pair from a sibling subdomain.
if err != nil || cookie == "" || header == "" || header != cookie || !validateCSRFToken(cookie) {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: missing or invalid CSRF token",
})
return
}
c.Next()
}
}