235 lines
7.3 KiB
Go
235 lines
7.3 KiB
Go
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 #9:CSRF 从 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")
|
||
}
|
||
}
|