- #20 AuthRequired 改为 AuthRequired(db):受保护路由每次回库校验 Status == StatusNormal 且未软删,失败清 session(保留 lang/csrf_token, 与登录轮换口径一致)并 302 /login;session user_id 先断言为数值再入 GORM(呼应 #19),AdminRequired 同步加固;SetUserContext 仅在用户 存在且状态正常时置 is_logged_in——禁用用户发评论不再自动通过, 回落游客审核策略 - #21 头像两个分支(UploadAvatar / UpdateProfile)强制 Category == image,解码失败直接拒绝、删除"回退存原始字节"路径, 统一经 processAvatar 解码→256px 缩放→JPEG 重编码;addUploadFileType 增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs), 拒绝添加并在上传设置页提示(模板 + 中英 i18n) - 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠 回退存原始字节掩盖,PNG 头像从未真正处理过),补 blank import - 新增回归测试 session_upload_security_test.go(6 用例:禁用/锁定/ 软删旧 cookie 302、锁定用户评论转 pending、6 组危险扩展拒绝、 伪装扩展名头像拒绝且磁盘零写入、正常图片转存 .jpg;已变异验证: 去掉任一修复对应测试即失败) - SECURITY_TODO.md 勾选 #20/#21 并更新执行顺序
477 lines
16 KiB
Go
477 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 wires a router that mirrors the production middleware chain
|
|
// (sessions -> CSRF -> user context) plus the routes under test.
|
|
type securityTestEnv struct {
|
|
router *gin.Engine
|
|
db *gorm.DB
|
|
storageDir string
|
|
}
|
|
|
|
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()
|
|
|
|
// Seed the upload policy so ValidateUpload accepts .txt files.
|
|
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)
|
|
|
|
// Seed users.
|
|
mustUser(t, db, "admin", models.RoleAdmin)
|
|
alice := mustUser(t, db, "alice", models.RoleAuthor)
|
|
bob := mustUser(t, db, "bob", models.RoleAuthor)
|
|
|
|
// Seed one article per author.
|
|
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"))
|
|
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))
|
|
r.POST("/logout", Logout())
|
|
r.POST("/article/:slug/comments", PostComment(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)
|
|
})
|
|
}
|
|
|
|
// Profile routes (avatar upload XSS-chain regression coverage, #21).
|
|
profile := r.Group("/profile", middleware.AuthRequired(db))
|
|
{
|
|
profile.POST("", UpdateProfile(db, storageDir))
|
|
profile.POST("/avatar", UploadAvatar(db, storageDir))
|
|
}
|
|
|
|
// Upload settings routes (dangerous-extension blacklist coverage, #21).
|
|
adminSettings := r.Group("/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
|
{
|
|
adminSettings.POST("/upload", UploadSettingsSave(db))
|
|
}
|
|
|
|
// Admin user-management routes (SQL-injection regression coverage, #19).
|
|
admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
|
{
|
|
admin.GET("/users/:id/edit", UserEditPage(db))
|
|
admin.POST("/users/:id/edit", UserUpdate(db))
|
|
admin.POST("/users/:id/delete", UserDelete(db))
|
|
}
|
|
|
|
return &securityTestEnv{router: r, db: db, storageDir: storageDir}
|
|
}
|
|
|
|
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 performs the full login flow (GET the form for a CSRF token, then POST
|
|
// credentials) and returns the authenticated session cookie.
|
|
func (e *securityTestEnv) login(t *testing.T, username string) string {
|
|
t.Helper()
|
|
|
|
// Anonymous GET to obtain CSRF token + session 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 credentials with the token.
|
|
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 extracts the blog_session cookie from a recorder. When
|
|
// several Set-Cookie headers are present (e.g. middleware and handler both
|
|
// save the session), the LAST one is the effective value - browsers apply
|
|
// them in order.
|
|
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 fetches a fresh CSRF token for an authenticated session.
|
|
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)
|
|
|
|
// Obtain an anonymous session (pre-login 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)")
|
|
}
|
|
|
|
// The authenticated session works.
|
|
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())
|
|
}
|
|
|
|
// The old (fixated) session must NOT carry the login.
|
|
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")
|
|
|
|
// Own article: allowed.
|
|
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)
|
|
}
|
|
|
|
// Someone else's article: forbidden.
|
|
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)
|
|
|
|
// Upload pending (article_id=0 + session token): allowed.
|
|
w := e.upload(t, alice, token, "")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("pending upload: status = %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Upload to someone else's article: forbidden.
|
|
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 uploads an attachment to her own article.
|
|
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 uploads an attachment to his own article.
|
|
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 cannot delete Bob's attachment.
|
|
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 can delete his own.
|
|
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's attachment record should be gone.
|
|
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")
|
|
|
|
// POST without a CSRF token must be rejected before reaching the handler.
|
|
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)
|
|
|
|
// Admin may list and upload to any article.
|
|
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())
|
|
}
|
|
|
|
// Clean up files created during the test (best effort).
|
|
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)
|
|
}
|
|
|
|
// Malicious :id values. Before the #19 fix, GORM interpolated a
|
|
// non-numeric single string cond into the WHERE clause raw
|
|
// (e.g. 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 edit page must redirect instead of rendering a matched row.
|
|
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 update must not modify anything (attempt role escalation).
|
|
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 delete must not delete anything.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// No user was modified or removed by any of the payloads.
|
|
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)
|
|
}
|
|
|
|
// Sanity: a valid numeric id still works.
|
|
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
|
|
}
|