Files
tamabox/internal/route/csrf_binding_test.go
T
2026-09-08 10:10:44 +08:00

121 lines
4.4 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 默认的 "userID"。
//
// 背景:flamego/csrf 的校验是无状态 HMAC 重算(token = HMAC(secret, id:action:ts),不与
// session 内存储的 token 比对)。一旦 SessionKey 与登录时写入的会话键(login.go 中的
// Session.Set("uid", ...))不一致,token 内绑定的 id 恒为 "0",任意匿名访客从公开 /login
// 页面取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站请求伪造可自由发起。
//
// 本测试用内存会话(无需数据库)重放「会话 B 携带会话 A 的 token 提交」这一跨会话复用攻击:
// - 会话 A(已登录 uid=42)携带自己的 token 提交 → 必须通过(200);
// - 会话 B(匿名)携带会话 A 的 token 跨会话提交 → 必须被拒绝(400)。
//
// 若 CSRFUserSessionKey 被误改回 "userID",第二项断言会失败,CI 即报警。
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
}))
// 受保护路由:仅当 CSRF 校验通过才返回 200;失败由 csrf.Error 写 400。
f.Post("/protected", func(ctx flamego.Context, x csrf.CSRF) {
x.Validate(ctx)
// 校验失败时 x.Validate 已写入 400;此处 Write 在已写 400 时仅追加 body,
// 状态保持 400;校验通过时此处隐式返回 200。
ctx.ResponseWriter().Write([]byte("ok"))
})
// 生成 token 的 GET 路由(token 在 GET 时生成,与真实应用一致)。
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)
// 同一会话携带自己的 token 提交 —— 必须通过(200)。
if code := postProtected(clientA, tokenA); code != http.StatusOK {
t.Fatalf("会话 A 携带自身 token 提交,期望 200,实际 %d(修复后不应被拒绝)", code)
}
// 会话 B:匿名访客,跨会话复用会话 A 的 token 提交 —— 必须被拒绝(400)。
// 这是 P1-1 的核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配。
clientB := newClient()
if code := postProtected(clientB, tokenA); code != http.StatusBadRequest {
t.Fatalf("会话 B 跨会话复用会话 A 的 token,期望 400,实际 %d(CSRF 绑定失效!)", code)
}
}