Files
nezha_domains/cmd/dashboard/controller/csrf_signed_test.go
T
naibaandcloudcode 834ae25024 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

85 lines
2.9 KiB
Go

package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func withCSRFSecret(t *testing.T, secret string) {
t.Helper()
prev := singleton.Conf
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}}
singleton.Conf.JWTSecretKey = secret
t.Cleanup(func() { singleton.Conf = prev })
}
// Sibling-subdomain cookie tossing: an attacker who can set nz-csrf for the
// parent domain injects an attacker-chosen value and mirrors it into the
// header. A naive double-submit accepts header==cookie. The signed
// double-submit must reject it because the injected value carries no valid
// server HMAC.
func TestCSRFMiddleware_RejectsUnsignedInjectedPair(t *testing.T) {
withCSRFSecret(t, "test-jwt-secret")
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-jwt", Value: "session"})
c.Request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "attacker-chosen"})
c.Request.Header.Set(csrfHeaderName, "attacker-chosen")
mw(c)
if !c.IsAborted() || w.Code != http.StatusForbidden {
t.Fatalf("an unsigned (cookie-tossed) csrf pair must be rejected, got aborted=%v code=%d", c.IsAborted(), w.Code)
}
}
// A token minted by the server (issueCSRFToken) must pass when mirrored
// correctly into the header.
func TestCSRFMiddleware_AcceptsServerSignedPair(t *testing.T) {
withCSRFSecret(t, "test-jwt-secret")
token := issueCSRFToken()
if token == "" {
t.Fatal("issueCSRFToken must produce a value")
}
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-jwt", Value: "session"})
c.Request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token})
c.Request.Header.Set(csrfHeaderName, token)
mw(c)
if c.IsAborted() {
t.Fatal("a correctly mirrored server-signed csrf pair must pass")
}
}
// Even with header==cookie, a value whose signature does not verify under the
// server secret must be rejected (forged signature segment).
func TestCSRFMiddleware_RejectsForgedSignature(t *testing.T) {
withCSRFSecret(t, "test-jwt-secret")
token := issueCSRFToken()
forged := token[:len(token)-1]
if token[len(token)-1] == 'a' {
forged += "b"
} else {
forged += "a"
}
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: forged})
c.Request.Header.Set(csrfHeaderName, forged)
mw(c)
if !c.IsAborted() || w.Code != http.StatusForbidden {
t.Fatalf("a forged-signature csrf pair must be rejected, got aborted=%v code=%d", c.IsAborted(), w.Code)
}
}