- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文 - 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等 - 代码、字符串字面量、日志消息保持英文原文,零逻辑改动 - go build/vet 通过,go test -count=1 ./... 全绿
482 lines
16 KiB
Go
482 lines
16 KiB
Go
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
|
||
}
|