chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
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)
}
}