Files
go_blog/handlers/security_test.go
T
kevin f307781f58 docs: 全部 Go 代码注释汉化
- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文
- 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等
- 代码、字符串字面量、日志消息保持英文原文,零逻辑改动
- go build/vet 通过,go test -count=1 ./... 全绿
2026-08-27 19:03:03 +08:00

482 lines
16 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 (
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"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.POST("/login", Login(db, limiter))
r.POST("/logout", Logout())
r.POST("/article/:slug/comments", PostComment(db))
r.GET("/register", RegisterPage(db))
r.POST("/register", Register(db))
r.GET("/rss", RSSFeed(db))
protected := r.Group("/my", middleware.AuthRequired(db))
{
protected.POST("/articles/attachments", UploadAttachment(db, storageDir))
protected.POST("/articles/attachments/:id/delete", DeleteAttachment(db, storageDir))
protected.GET("/articles/:id/attachments", ListAttachments(db))
protected.GET("/whoami", func(c *gin.Context) {
uid, _ := sessionAuthorID(c)
c.String(http.StatusOK, "uid=%d", uid)
})
}
// 个人资料路由(头像上传 XSS 链回归覆盖,#21)。
profile := r.Group("/profile", middleware.AuthRequired(db))
{
profile.POST("", UpdateProfile(db, storageDir))
profile.POST("/avatar", UploadAvatar(db, storageDir))
}
// 上传设置路由(危险扩展名黑名单覆盖,#21)。
adminSettings := r.Group("/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db))
{
adminSettings.POST("/upload", UploadSettingsSave(db))
}
// 后台用户管理路由(SQL 注入回归覆盖,#19)。
admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db))
{
admin.POST("/users/new", UserCreate(db))
admin.GET("/users/:id/edit", UserEditPage(db))
admin.POST("/users/:id/edit", UserUpdate(db))
admin.POST("/users/:id/delete", UserDelete(db))
admin.GET("/comments", CommentListPage(db))
}
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 凭据。
form := url.Values{}
form.Set("username", username)
form.Set("password", "pw-"+username)
form.Set("_csrf", m[1])
req = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
w = httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("POST /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
}
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")
}
mw.WriteField("_csrf", csrfToken)
fw, _ := mw.CreateFormFile("file", "hello.txt")
fw.Write([]byte("hello world"))
mw.Close()
return e.do(http.MethodPost, "/my/articles/attachments", cookie, strings.NewReader(buf.String()), mw.FormDataContentType())
}
// 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)
}
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("login page did not render a CSRF token")
}
return m[1]
}
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("/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("/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 的附件。
form := url.Values{}
form.Set("_csrf", token)
w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), alice,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if w.Code != http.StatusForbidden {
t.Fatalf("delete other user's attachment: status = %d, want 403", w.Code)
}
// Bob 可以删除自己的附件。
form.Set("_csrf", bobToken)
w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), bob,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
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, "/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("/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)
}
// POST 更新不得修改任何内容(尝试提权)。
form := url.Values{}
form.Set("_csrf", token)
form.Set("role", models.RoleAdmin)
form.Set("status", "1")
form.Set("display_name", "hacked")
w = e.do(http.MethodPost, "/admin/users/"+url.PathEscape(id)+"/edit", admin,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if w.Code != http.StatusFound || w.Header().Get("Location") != "/admin/users" {
t.Fatalf("POST edit with id %q: status = %d, location = %q", id, w.Code, w.Header().Get("Location"))
}
// POST 删除不得删除任何内容。
form = url.Values{}
form.Set("_csrf", token)
w = e.do(http.MethodPost, "/admin/users/"+url.PathEscape(id)+"/delete", admin,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if w.Code != http.StatusFound {
t.Fatalf("POST delete with id %q: status = %d, want 302", id, w.Code)
}
}
// 任何载荷都不应修改或删除用户。
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
}