- 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 ./... 全绿
572 lines
18 KiB
Go
572 lines
18 KiB
Go
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
|
||
}
|