109 lines
3.5 KiB
Go
109 lines
3.5 KiB
Go
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)
|
||
}
|
||
}
|