Files
go_blog/handlers/security_test.go
T
kevin 7f0beb5b0c test: 新增 api_test.go——/api 认证/越权/文章/评论/注册端到端断言
- TestAPIAuthRequiredReturnsJSON:未登录打 /api 路由返回 401 JSON
  (携带合法 CSRF 模拟真实前端顺序)
- TestAPIAdminRequiredReturnsJSON:非管理员 403 api_forbidden
- TestAPICSRFHeaderRequired:缺 CSRF 头 403
- TestAPILoginRoleRedirect:登录成功按角色返回 redirect(/admin | /)
- TestAPIArticleCRUD:admin 创建(slug 自动生成)/更新/校验 400/软删除
- TestAPIMyArticlesOwnership:跨作者 update 404 / delete 无效 / 创建成功
- TestAPICommentValidationCodes:评论校验 code + 成功 redirect 锚点
- TestAPIRegisterConflictAndMismatch:409 用户名冲突 / 400 密码不一致
- 测试环境补齐:env 路由增 /api/admin/articles 与 /api/my/articles CRUD;
  attachments URL 迁 /api(upload/delete 改 DELETE+CSRF 头),
  p3 附件用例 URL 同步
- 新增 anonSession/csrfTokenFrom/loginRequest/itoa/deleteAttachment 辅助
- go build/vet/test ./... 全绿
2026-08-27 19:59:25 +08:00

572 lines
18 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 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 获取匿名会话 CookieGET /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
}