package handlers import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "testing" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "gorm.io/gorm" "go_blog/middleware" "go_blog/models" ) // securityTestEnv 搭建与生产中间件链一致的路由器 // (sessions -> CSRF -> 用户上下文),外加待测路由。 type securityTestEnv struct { router *gin.Engine db *gorm.DB storageDir string limiter *LoginRateLimiter } var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`) func newSecurityTestEnv(t *testing.T) *securityTestEnv { t.Helper() gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.Attachment{}, &models.SiteSetting{}, &models.UploadConfig{}, &models.UploadFileType{}, &models.CommentConfig{}, &models.NavLink{}, &models.DownloadBaseURL{}, &models.Comment{}); err != nil { t.Fatalf("migrate: %v", err) } storageDir := t.TempDir() // 初始化上传策略,使 ValidateUpload 接受 .txt 文件。 db.Create(&models.SiteSetting{ID: 1}) db.Create(&models.CommentConfig{ID: 1, Enabled: true, AllowGuest: true}) db.Create(&models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: 1024 * 1024, StorageDir: "attachments"}) db.Create(&models.UploadFileType{Extension: ".txt", MimeType: "text/plain", Category: models.CategoryDocument, Enabled: true}) models.LoadConfigCache(db) // 初始化用户。 mustUser(t, db, "admin", models.RoleAdmin) alice := mustUser(t, db, "alice", models.RoleAuthor) bob := mustUser(t, db, "bob", models.RoleAuthor) // 每位作者初始化一篇文章。 aliceArt := models.Article{AuthorID: alice.ID, Title: "alice post", Slug: "alice-post", Content: "x", Status: models.ArticlePublished} bobArt := models.Article{AuthorID: bob.ID, Title: "bob post", Slug: "bob-post", Content: "x", Status: models.ArticlePublished} db.Create(&aliceArt) db.Create(&bobArt) r := gin.New() if err := r.SetTrustedProxies(nil); err != nil { t.Fatalf("set trusted proxies: %v", err) } r.LoadHTMLGlob("../templates/**/*.html") store := cookie.NewStore([]byte("test-secret")) limiter := NewLoginLimiter() r.Use(sessions.Sessions("blog_session", store)) r.Use(middleware.CSRFProtect()) r.Use(middleware.SetUserContext(db)) r.GET("/login", LoginPage()) r.GET("/register", RegisterPage(db)) r.GET("/rss", RSSFeed(db)) api := r.Group("/api") { api.POST("/auth/login", Login(db, limiter)) api.POST("/auth/logout", Logout()) api.POST("/auth/register", Register(db)) api.POST("/article/:slug/comments", PostComment(db)) } protected := r.Group("/my", middleware.AuthRequired(db)) { protected.GET("/whoami", func(c *gin.Context) { uid, _ := sessionAuthorID(c) c.String(http.StatusOK, "uid=%d", uid) }) } myAPI := r.Group("/api/my/articles", middleware.AuthRequired(db)) { myAPI.POST("", MyArticleCreate(db)) myAPI.PUT("/:id", MyArticleUpdate(db)) myAPI.DELETE("/:id", MyArticleDelete(db)) myAPI.POST("/attachments", UploadAttachment(db, storageDir)) myAPI.DELETE("/attachments/:id", DeleteAttachment(db, storageDir)) myAPI.GET("/:id/attachments", ListAttachments(db)) } // 个人资料 API(头像上传 XSS 链回归覆盖,#21)。 profileAPI := r.Group("/api/profile", middleware.AuthRequired(db)) { profileAPI.POST("", UpdateProfile(db, storageDir)) profileAPI.POST("/avatar", UploadAvatar(db, storageDir)) } // 上传设置 API(危险扩展名黑名单覆盖,#21)。 adminSettingsAPI := r.Group("/api/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db)) { adminSettingsAPI.POST("/upload", UploadSettingsSave(db)) } // 后台用户管理路由(SQL 注入回归覆盖,#19)。 admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db)) { admin.GET("/users/:id/edit", UserEditPage(db)) admin.GET("/comments", CommentListPage(db)) } usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db)) { usersAPI.POST("", UserCreate(db)) usersAPI.PUT("/:id", UserUpdate(db)) usersAPI.DELETE("/:id", UserDelete(db)) } articleAPI := r.Group("/api/admin/articles", middleware.AuthRequired(db), middleware.AdminRequired(db)) { articleAPI.POST("", ArticleCreate(db, "/admin")) articleAPI.PUT("/:id", ArticleUpdate(db, "/admin/articles")) articleAPI.DELETE("/:id", ArticleDelete(db, "/admin/articles")) } return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter} } func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User { t.Helper() u := models.User{Username: username, DisplayName: username, Role: role, Status: models.StatusNormal} if err := u.SetPassword("pw-" + username); err != nil { t.Fatalf("set password: %v", err) } if err := db.Create(&u).Error; err != nil { t.Fatalf("create user %s: %v", username, err) } return u } // login 执行完整登录流程(GET 表单获取 CSRF 令牌,再 POST 凭据), // 返回认证后的会话 Cookie。 func (e *securityTestEnv) login(t *testing.T, username string) string { t.Helper() // 匿名 GET 获取 CSRF 令牌 + 会话 Cookie。 req := httptest.NewRequest(http.MethodGet, "/login", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("GET /login: status = %d", w.Code) } m := csrfTokenRe.FindStringSubmatch(w.Body.String()) if m == nil { t.Fatal("login page did not render a CSRF token") } cookie := e.sessionCookie(w) // 携带令牌 POST 凭据(JSON API)。 w = postJSON(e, http.MethodPost, "/api/auth/login", cookie, m[1], gin.H{ "username": username, "password": "pw-" + username, }) if w.Code != http.StatusOK { t.Fatalf("POST /api/auth/login (%s): status = %d, body %s", username, w.Code, w.Body.String()) } authCookie := e.sessionCookie(w) if authCookie == "" { t.Fatal("login did not set a session cookie") } return authCookie } // sessionCookie 从记录器中提取 blog_session Cookie。当存在多个 Set-Cookie // 头时(例如中间件和处理器都保存了会话),最后一个才是生效值—— // 浏览器按顺序应用它们。 func (e *securityTestEnv) sessionCookie(w *httptest.ResponseRecorder) string { cookie := "" for _, c := range w.Result().Cookies() { if c.Name == "blog_session" { cookie = c.Name + "=" + c.Value } } return cookie } func (e *securityTestEnv) do(method, path, cookie string, body io.Reader, contentType string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, body) if contentType != "" { req.Header.Set("Content-Type", contentType) } if cookie != "" { req.Header.Set("Cookie", cookie) } w := httptest.NewRecorder() e.router.ServeHTTP(w, req) return w } // postJSON 是 JSON 请求小助手,以 X-CSRF-Token 请求头发送令牌(AJAX 模式)。 func postJSON(e *securityTestEnv, method, path, cookie, csrfToken string, body interface{}) *httptest.ResponseRecorder { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { panic(err) } req := httptest.NewRequest(method, path, &buf) req.Header.Set("Content-Type", "application/json") if csrfToken != "" { req.Header.Set("X-CSRF-Token", csrfToken) } if cookie != "" { req.Header.Set("Cookie", cookie) } w := httptest.NewRecorder() e.router.ServeHTTP(w, req) return w } // respCode 从 JSON 错误响应中提取 code 字段。 func respCode(w *httptest.ResponseRecorder) string { var r struct { Code string `json:"code"` } _ = json.Unmarshal(w.Body.Bytes(), &r) return r.Code } // respRedirect 从 JSON 成功响应中提取 redirect 字段。 func respRedirect(w *httptest.ResponseRecorder) string { var r struct { Redirect string `json:"redirect"` } _ = json.Unmarshal(w.Body.Bytes(), &r) return r.Redirect } // respOK 报告 JSON 响应是否成功(ok=true)。 func respOK(w *httptest.ResponseRecorder) bool { var r struct { OK bool `json:"ok"` } _ = json.Unmarshal(w.Body.Bytes(), &r) return r.OK } func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID string) *httptest.ResponseRecorder { t.Helper() var buf strings.Builder mw := multipart.NewWriter(&buf) if articleID != "" { mw.WriteField("article_id", articleID) } else { mw.WriteField("session_token", "test-pending-token") } fw, _ := mw.CreateFormFile("file", "hello.txt") fw.Write([]byte("hello world")) mw.Close() req := httptest.NewRequest(http.MethodPost, "/api/my/articles/attachments", strings.NewReader(buf.String())) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("X-CSRF-Token", csrfToken) if cookie != "" { req.Header.Set("Cookie", cookie) } w := httptest.NewRecorder() e.router.ServeHTTP(w, req) return w } // deleteAttachment 以 DELETE + CSRF 头删除附件。 func (e *securityTestEnv) deleteAttachment(t *testing.T, cookie, csrfToken string, id uint) *httptest.ResponseRecorder { t.Helper() return postJSON(e, http.MethodDelete, fmt.Sprintf("/api/my/articles/attachments/%d", id), cookie, csrfToken, nil) } // csrfTokenFor 为已认证会话获取一个全新的 CSRF 令牌。 func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string { t.Helper() w := e.do(http.MethodGet, "/login", cookie, nil, "") if w.Code != http.StatusOK { t.Fatalf("GET /login: status = %d", w.Code) } return e.csrfTokenFrom(t, w) } // anonSession 获取匿名会话 Cookie(GET /login)。 func (e *securityTestEnv) anonSession() (string, *httptest.ResponseRecorder) { w := e.do(http.MethodGet, "/login", "", nil, "") return e.sessionCookie(w), w } // csrfTokenFrom 从 GET /login 响应体解析 CSRF 令牌。 func (e *securityTestEnv) csrfTokenFrom(t *testing.T, w *httptest.ResponseRecorder) string { t.Helper() m := csrfTokenRe.FindStringSubmatch(w.Body.String()) if m == nil { t.Fatal("login page did not render a CSRF token") } return m[1] } // loginRequest 以 JSON 方式以给定凭据提交登录,返回响应。 func (e *securityTestEnv) loginRequest(t *testing.T, username, password string) *httptest.ResponseRecorder { t.Helper() anonCookie, w := e.anonSession() token := e.csrfTokenFrom(t, w) return postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, token, gin.H{"username": username, "password": password}) } // itoa 将 uint 转为十进制字符串(测试辅助)。 func itoa(v uint) string { return strconv.FormatUint(uint64(v), 10) } func TestLoginRotatesSession(t *testing.T) { e := newSecurityTestEnv(t) // 获取匿名会话(登录前的 Cookie)。 req := httptest.NewRequest(http.MethodGet, "/login", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) preLoginCookie := e.sessionCookie(w) if preLoginCookie == "" { t.Fatal("expected anonymous session cookie") } authCookie := e.login(t, "alice") if authCookie == preLoginCookie { t.Fatal("session cookie was not rotated on login (fixation risk)") } // 认证会话可正常工作。 w = e.do(http.MethodGet, "/my/whoami", authCookie, nil, "") if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "uid=") { t.Fatalf("authenticated request failed: status=%d body=%s", w.Code, w.Body.String()) } // 旧的(被固定的)会话不得携带登录状态。 w = e.do(http.MethodGet, "/my/whoami", preLoginCookie, nil, "") if w.Code != http.StatusFound { t.Fatalf("pre-login session still authenticated after login: status=%d", w.Code) } } func TestAttachmentListRequiresOwnership(t *testing.T) { e := newSecurityTestEnv(t) var aliceArt, bobArt models.Article e.db.Where("slug = ?", "alice-post").First(&aliceArt) e.db.Where("slug = ?", "bob-post").First(&bobArt) alice := e.login(t, "alice") // 自己的文章:允许。 w := e.do(http.MethodGet, fmt.Sprintf("/api/my/articles/%d/attachments", aliceArt.ID), alice, nil, "") if w.Code != http.StatusOK { t.Fatalf("list own attachments: status = %d, want 200", w.Code) } // 他人的文章:禁止。 w = e.do(http.MethodGet, fmt.Sprintf("/api/my/articles/%d/attachments", bobArt.ID), alice, nil, "") if w.Code != http.StatusForbidden { t.Fatalf("list other user's attachments: status = %d, want 403", w.Code) } } func TestAttachmentUploadRequiresOwnership(t *testing.T) { e := newSecurityTestEnv(t) var bobArt models.Article e.db.Where("slug = ?", "bob-post").First(&bobArt) alice := e.login(t, "alice") token := e.csrfTokenFor(t, alice) // 上传待绑定附件(article_id=0 + 会话令牌):允许。 w := e.upload(t, alice, token, "") if w.Code != http.StatusOK { t.Fatalf("pending upload: status = %d, body %s", w.Code, w.Body.String()) } // 上传到他人文章:禁止。 w = e.upload(t, alice, token, fmt.Sprint(bobArt.ID)) if w.Code != http.StatusForbidden { t.Fatalf("upload to other user's article: status = %d, want 403", w.Code) } } func TestAttachmentDeleteRequiresOwnership(t *testing.T) { e := newSecurityTestEnv(t) var aliceArt, bobArt models.Article e.db.Where("slug = ?", "alice-post").First(&aliceArt) e.db.Where("slug = ?", "bob-post").First(&bobArt) alice := e.login(t, "alice") token := e.csrfTokenFor(t, alice) // Alice 上传附件到自己的文章。 w := e.upload(t, alice, token, fmt.Sprint(aliceArt.ID)) if w.Code != http.StatusOK { t.Fatalf("upload: status = %d, body %s", w.Code, w.Body.String()) } // Bob 上传附件到自己的文章。 bob := e.login(t, "bob") bobToken := e.csrfTokenFor(t, bob) w = e.upload(t, bob, bobToken, fmt.Sprint(bobArt.ID)) if w.Code != http.StatusOK { t.Fatalf("upload (bob): status = %d, body %s", w.Code, w.Body.String()) } var bobAtt models.Attachment if err := e.db.Where("uploader_id = ?", userIDByUsername(t, e.db, "bob")).First(&bobAtt).Error; err != nil { t.Fatalf("bob attachment not found: %v", err) } // Alice 不能删除 Bob 的附件。 w = e.deleteAttachment(t, alice, token, bobAtt.ID) if w.Code != http.StatusForbidden { t.Fatalf("delete other user's attachment: status = %d, want 403", w.Code) } // Bob 可以删除自己的附件。 w = e.deleteAttachment(t, bob, bobToken, bobAtt.ID) if w.Code != http.StatusOK { t.Fatalf("delete own attachment: status = %d, want 200 (body %s)", w.Code, w.Body.String()) } // Bob 的附件记录应该已删除。 var count int64 e.db.Model(&models.Attachment{}).Where("id = ?", bobAtt.ID).Count(&count) if count != 0 { t.Fatal("attachment was not deleted") } } func TestAttachmentCSRFEnforced(t *testing.T) { e := newSecurityTestEnv(t) alice := e.login(t, "alice") // 未携带 CSRF 令牌的 POST 必须在到达处理器前被拒绝。 var buf strings.Builder mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("file", "hello.txt") fw.Write([]byte("hello")) mw.Close() w := e.do(http.MethodPost, "/api/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType()) if w.Code != http.StatusForbidden { t.Fatalf("upload without CSRF token: status = %d, want 403", w.Code) } } func TestAttachmentAdminOverride(t *testing.T) { e := newSecurityTestEnv(t) var bobArt models.Article e.db.Where("slug = ?", "bob-post").First(&bobArt) admin := e.login(t, "admin") token := e.csrfTokenFor(t, admin) // 管理员可以列出和上传到任意文章。 w := e.do(http.MethodGet, fmt.Sprintf("/api/my/articles/%d/attachments", bobArt.ID), admin, nil, "") if w.Code != http.StatusOK { t.Fatalf("admin list: status = %d, want 200", w.Code) } w = e.upload(t, admin, token, fmt.Sprint(bobArt.ID)) if w.Code != http.StatusOK { t.Fatalf("admin upload: status = %d, want 200 (body %s)", w.Code, w.Body.String()) } // 清理测试期间创建的文件(尽力而为)。 entries, _ := os.ReadDir(filepath.Join(e.storageDir, "attachments")) for _, ent := range entries { os.Remove(filepath.Join(e.storageDir, "attachments", ent.Name())) } } func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) { e := newSecurityTestEnv(t) admin := e.login(t, "admin") token := e.csrfTokenFor(t, admin) var alice models.User if err := e.db.Where("username = ?", "alice").First(&alice).Error; err != nil { t.Fatalf("alice not found: %v", err) } // 恶意的 :id 值。在 #19 修复前,GORM 会把非数值的单一字符串条件 // 原样插值进 WHERE 子句(例如 WHERE 1 OR 1=1)。 ids := []string{ "1 OR 1=1", "1;--", "1) OR (1=1", "1 UNION SELECT 1", "alice", } for _, id := range ids { // GET 编辑页必须重定向而非渲染匹配到的行。 w := e.do(http.MethodGet, "/admin/users/"+url.PathEscape(id)+"/edit", admin, nil, "") if w.Code != http.StatusFound { t.Fatalf("GET edit with id %q: status = %d, want 302", id, w.Code) } if loc := w.Header().Get("Location"); loc != "/admin/users" { t.Fatalf("GET edit with id %q: location = %q, want /admin/users", id, loc) } // PUT 更新不得修改任何内容(尝试提权)。 w = postJSON(e, http.MethodPut, "/api/admin/users/"+url.PathEscape(id), admin, token, gin.H{"role": models.RoleAdmin, "status": 1, "display_name": "hacked"}) if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" { t.Fatalf("PUT edit with id %q: status = %d, code = %q", id, w.Code, respCode(w)) } // DELETE 删除不得删除任何内容。 w = postJSON(e, http.MethodDelete, "/api/admin/users/"+url.PathEscape(id), admin, token, nil) if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" { t.Fatalf("DELETE user with id %q: status = %d, code = %q", id, w.Code, respCode(w)) } } // 任何载荷都不应修改或删除用户。 var count int64 e.db.Model(&models.User{}).Count(&count) if count != 3 { t.Fatalf("user count = %d, want 3 (injection removed rows)", count) } var check models.User if err := e.db.Where("username = ?", "alice").First(&check).Error; err != nil { t.Fatalf("alice gone: %v", err) } if check.Role != models.RoleAuthor || check.DisplayName != "alice" { t.Fatalf("alice modified via id injection: role=%q display=%q", check.Role, check.DisplayName) } // 健全性检查:合法的数值 id 仍然有效。 w := e.do(http.MethodGet, fmt.Sprintf("/admin/users/%d/edit", alice.ID), admin, nil, "") if w.Code != http.StatusOK { t.Fatalf("GET edit with valid id: status = %d, want 200", w.Code) } } func userIDByUsername(t *testing.T, db *gorm.DB, username string) uint { t.Helper() var u models.User if err := db.Where("username = ?", username).First(&u).Error; err != nil { t.Fatalf("user %s not found: %v", username, err) } return u.ID }