Files
tamabox/internal/route/csrf_binding_test.go
T

109 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package route
import (
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/session"
)
// TestCSRFTokenBoundToUserSession 是 P1-1 的回归测试:CSRF token 必须绑定到登录用户的
// 会话键(CSRFUserSessionKey="uid"),否则 flamego/csrf 的无状态 HMAC 重算会让匿名访客
// 取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站伪造可自由发起。
// 测试用内存会话重放跨会话复用攻击:会话 A(uid=42)自带的 token 提交须 200,会话 B 跨会话
// 复用会话 A 的 token 须 400。若 CSRFUserSessionKey 被误改回 "userID",第二项断言即失败报警。
func TestCSRFTokenBoundToUserSession(t *testing.T) {
const secret = "test-secret-for-csrf-regression"
f := flamego.New()
f.Use(session.Sessioner(session.Options{
Initer: session.MemoryIniter(),
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
},
}))
// 仅当请求带 X-Test-Login 头时才把 uid=42 写入会话,模拟「已登录用户」。
// 该中间件必须排在 csrf 之前,使 token 在生成时即绑定到 uid。
f.Use(func(ctx flamego.Context, s session.Session) {
if ctx.Request().Header.Get("X-Test-Login") != "" {
s.Set(CSRFUserSessionKey, uint(42))
}
})
f.Use(csrf.Csrfer(csrf.Options{
Secret: secret,
Header: "X-CSRF-Token",
SessionKey: CSRFUserSessionKey,
SetHeader: true, // 便于测试从响应头取出 token
}))
// 受保护路由:校验通过才 200;失败由 csrf.Error 写 400。
f.Post("/protected", func(ctx flamego.Context, x csrf.CSRF) {
x.Validate(ctx)
// 校验失败时 x.Validate 已写 400,此处 Write 仅追加 body,状态保持 400。
ctx.ResponseWriter().Write([]byte("ok"))
})
f.Get("/token", func(ctx flamego.Context) {
ctx.ResponseWriter().Write([]byte("ok"))
})
ts := httptest.NewServer(f)
defer ts.Close()
newClient := func() *http.Client {
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("cookiejar.New: %v", err)
}
return &http.Client{Jar: jar}
}
getToken := func(client *http.Client, login bool) string {
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/token", nil)
if login {
req.Header.Set("X-Test-Login", "1")
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET /token: %v", err)
}
defer resp.Body.Close()
tok := resp.Header.Get("X-CSRF-Token")
if tok == "" {
t.Fatalf("GET /token 未返回 X-CSRF-Token(Csrfer SetHeader 应已启用)")
}
return tok
}
postProtected := func(client *http.Client, token string) int {
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/protected", nil)
if token != "" {
req.Header.Set("X-CSRF-Token", token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("POST /protected: %v", err)
}
defer resp.Body.Close()
return resp.StatusCode
}
// 会话 A:已登录用户(uid=42)。
clientA := newClient()
tokenA := getToken(clientA, true)
if code := postProtected(clientA, tokenA); code != http.StatusOK {
t.Fatalf("会话 A 携带自身 token 提交,期望 200,实际 %d(修复后不应被拒绝)", code)
}
// 核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配,须被拒绝(400)。
clientB := newClient()
if code := postProtected(clientB, tokenA); code != http.StatusBadRequest {
t.Fatalf("会话 B 跨会话复用会话 A 的 token,期望 400,实际 %d(CSRF 绑定失效!)", code)
}
}