项目文件夹

文件
2026-07-14 10:24:10 +08:00

235 行
7.3 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)
// 回归 C6b:TTL 过期分支。直接向包级存储注入一个“已过期”的 token,
// 断言 CSRFForAPI 拒绝它(即使该 token 从未被消费过)。
// 这条分支(time.Since(issuedAt) > apiTokenTTL)无法靠单次消费用例覆盖,
// 必须单独构造过期时间戳。
func TestCSRFForAPITTLExpiry(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRFForAPI())
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
expiredToken := "expired-test-token"
apiTokensMu.Lock()
// 注入一个早于 TTL 的颁发时间,模拟 token 已过期。
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
apiTokensMu.Unlock()
defer func() {
apiTokensMu.Lock()
delete(apiTokens, expiredToken)
apiTokensMu.Unlock()
}()
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/action", nil)
req.Header.Set("X-CSRF-Token", expiredToken)
r.ServeHTTP(w, req)
var resp response.Response
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response %q: %v", w.Body.String(), err)
}
if resp.Code == response.CodeSuccess {
t.Errorf("expired token must be rejected, got code=%d (success)", resp.Code)
}
}
// 回归 P1 #9CSRF 从 JSON body 取 token 后,下游 handler 仍能读到完整请求体。
// 修复前用 ShouldBindJSON 读干 body,下游再绑定拿到 EOF;改用 ShouldBindBodyWith 缓存 body。
func TestCSRFJSONBodyPreservedForDownstream(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF())
var gotData string
r.POST("/action", func(c *gin.Context) {
// 下游再次绑定 body,应能读到完整内容(而非被 CSRF 中间件读空)。
var payload struct {
Data string `json:"data"`
}
if err := c.ShouldBindBodyWith(&payload, binding.JSON); err != nil {
c.JSON(500, gin.H{"err": err.Error()})
return
}
gotData = payload.Data
c.JSON(200, gin.H{"status": "ok"})
})
// token 仅放在 JSON body 里(不放 header/form),触发中间件读 body 的分支。
const tok = "csrf-token-abc-1234567890"
body := `{"_csrf":"` + tok + `","data":"hello-downstream"}`
req := httptest.NewRequest("POST", "/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// cookie 携带同一 token,使 CSRF 校验通过。
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: tok})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d, body=%s", w.Code, w.Body.String())
}
if gotData != "hello-downstream" {
t.Errorf("downstream read data=%q, want 'hello-downstream' (P1 #9: body must survive CSRF)", gotData)
}
}
func TestCSRFJSONBodyTooLargeRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{MaxBodyBytes: 16}))
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
body := `{"_csrf":"token","data":"` + strings.Repeat("x", 64) + `"}`
req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: "token"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body.String())
}
}
func TestCSRFNegativeTokenLengthFallsBack(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"token": GetCSRFToken(c)}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if len(w.Result().Cookies()) == 0 {
t.Fatal("expected csrf cookie to be set")
}
}
func TestCSRFCookieSameSiteLax(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF())
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestCSRFPartialConfigKeepsCookieDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax for partial config", c.SameSite)
}
if c.MaxAge != DefaultCSRFConfig.MaxAge {
t.Fatalf("MaxAge = %d, want %d for partial config", c.MaxAge, DefaultCSRFConfig.MaxAge)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestDoubleSubmitCookieSameSiteLax(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(DoubleSubmitCookie())
r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/get", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestCSRFWithSkipAnchorsPathBoundary(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRFWithSkip([]string{"/api"}))
r.POST("/api", func(c *gin.Context) { c.Status(http.StatusNoContent) })
r.POST("/api/users", func(c *gin.Context) { c.Status(http.StatusNoContent) })
r.POST("/apix", func(c *gin.Context) { c.Status(http.StatusNoContent) })
for _, path := range []string{"/api", "/api/users"} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
if w.Code != http.StatusNoContent {
t.Fatalf("%s status = %d, want skipped 204", path, w.Code)
}
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/apix", nil))
if w.Code == http.StatusNoContent {
t.Fatal("/apix was skipped by /api prefix; want CSRF rejection")
}
}
func TestGenerateAPITokenCleansExpiredTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
expiredToken := "expired-before-issue"
apiTokensMu.Lock()
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
apiTokensMu.Unlock()
defer func() {
apiTokensMu.Lock()
delete(apiTokens, expiredToken)
apiTokensMu.Unlock()
}()
r := gin.New()
r.GET("/csrf-token", GenerateAPIToken)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/csrf-token", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
apiTokensMu.RLock()
_, exists := apiTokens[expiredToken]
apiTokensMu.RUnlock()
if exists {
t.Fatal("GenerateAPIToken did not clean expired token")
}
}