diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md index 4d82219..85f360a 100644 --- a/SECURITY_TODO.md +++ b/SECURITY_TODO.md @@ -209,13 +209,13 @@ - [x] SQLite 无 FOR UPDATE(且纯 Go 驱动连接池可并发读):叠加进程内互斥锁 `lastAdminMu`(应用按设计单实例部署,见 #10 限流器注释)闭合同进程竞态;事务内计数在写锁串行化后重读 - **验证**: ✅ `TestConcurrentLastAdminDowngrade`(两位管理员并发降级:恰好 1 成功 1 拒绝,最终管理员 ≥1,`-race` 通过) -### [ ] 31. 普通作者可置顶全站文章——需确认设计意图(2026-08-27 API 化复审新发现) +### [x] 31. 普通作者可置顶全站文章——需确认设计意图(2026-08-27 API 化复审新发现)✅ 2026-08-27 - **位置**: `handlers/article.go`(ArticleCreate/ArticleUpdate 由 /api/my/articles 复用)、`templates/user/my_article_form.html`(is_top 复选框) - **问题**: my 表单与 API 均接受 `is_top`——任意注册作者可把自己的文章钉在全站首页最顶端(`publishedArticleOrder` 为 is_top DESC 优先),还能自定 `published_at` 影响排序。若"作者可置顶自己的文章"非产品预期,属影响公共展示位的横向越权。 -- **修复**(若确认非设计意图): - - [ ] MyArticleCreate / MyArticleUpdate 强制 `is_top=false`(仅 admin 路径接受该字段) - - [ ] my_article_form.html 移除置顶复选框 -- **验证**: [ ] 测试:作者经 /api/my/articles 提交 is_top=true → 落库为 false;admin 路径不受影响 +- **修复**(确认非设计意图,予收紧): + - [x] MyArticleCreate/MyArticleUpdate 经共享实现 `articleCreate/articleUpdate(..., allowIsTop=false)` 强制 `is_top=false`;MyArticleUpdate 保留库中现有值(管理员授权的置顶不因作者编辑而丢失/取消) + - [x] my_article_form.html 移除置顶复选框(作者表单不再提供该字段) +- **验证**: ✅ `TestMyArticlesCannotPin`(作者 create/update 提交 is_top=true → 落库 false;admin 路径可正常置顶;作者编辑已置顶文章不丢失置顶) --- diff --git a/handlers/article.go b/handlers/article.go index 99ed9b9..a607ad3 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -267,6 +267,12 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { // ArticleCreate 处理创建新文章的 POST 请求(admin 与 my 共用)。 // redirectPath 是成功跳转目标(admin 用 /admin,普通用户用 /my/articles)。 func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc { + return articleCreate(db, redirectPath, true) +} + +// articleCreate 实现文章创建逻辑。allowIsTop=false 时普通作者的置顶请求 +// 被降级为 false(SECURITY_TODO #31:置顶全站为管理能力,仅 admin 路径可设)。 +func articleCreate(db *gorm.DB, redirectPath string, allowIsTop bool) gin.HandlerFunc { return func(c *gin.Context) { f, ok := parseArticleFormJSON(c) if !ok { @@ -319,7 +325,7 @@ func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc { Content: f.Content, Cover: f.Cover, Status: status, - IsTop: f.IsTop, + IsTop: f.IsTop && allowIsTop, PublishedAt: publishedAt, } @@ -401,6 +407,12 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc { // ArticleUpdate 处理更新现有文章的请求(admin 与 my 共用)。 // redirectPath 是成功跳转目标(admin 用 /admin/articles,普通用户用 /my/articles)。 func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc { + return articleUpdate(db, redirectPath, true) +} + +// articleUpdate 实现文章更新逻辑。allowIsTop=false 时普通作者提交的 +// 置顶值被忽略(SECURITY_TODO #31:仅 admin 路径可改置顶状态)。 +func articleUpdate(db *gorm.DB, redirectPath string, allowIsTop bool) gin.HandlerFunc { return func(c *gin.Context) { id := parseUintParam(c, "id") if id == 0 { @@ -459,7 +471,7 @@ func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc { "Content": f.Content, "Cover": f.Cover, "Status": newStatus, - "IsTop": f.IsTop, + "IsTop": f.IsTop && allowIsTop, "PublishedAt": publishedAt, } diff --git a/handlers/my_articles.go b/handlers/my_articles.go index 409861d..178c9f8 100644 --- a/handlers/my_articles.go +++ b/handlers/my_articles.go @@ -47,8 +47,9 @@ func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc { } // MyArticleCreate 处理普通用户创建新文章的 POST 请求。 +// 复用创建逻辑,普通作者不可置顶(SECURITY_TODO #31)。 func MyArticleCreate(db *gorm.DB) gin.HandlerFunc { - return ArticleCreate(db, "/my/articles") // 复用创建逻辑,跳转到 my 列表 + return articleCreate(db, "/my/articles", false) } // MyArticleEditPage 为已登录用户自己的文章渲染编辑表单。 @@ -137,13 +138,16 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { } updates := map[string]interface{}{ - "title": f.Title, - "slug": f.Slug, - "summary": f.Summary, - "content": f.Content, - "cover": f.Cover, - "status": newStatus, - "is_top": f.IsTop, + "title": f.Title, + "slug": f.Slug, + "summary": f.Summary, + "content": f.Content, + "cover": f.Cover, + "status": newStatus, + // SECURITY_TODO #31:作者不可修改置顶状态——保留库中 + // 现有值(管理员授权的置顶不因作者编辑而丢失,作者也无法 + // 自行置顶/取消置顶)。 + "is_top": article.IsTop, "published_at": publishedAt, } diff --git a/handlers/pin_privilege_test.go b/handlers/pin_privilege_test.go new file mode 100644 index 0000000..a687940 --- /dev/null +++ b/handlers/pin_privilege_test.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + "go_blog/models" +) + +// TestMyArticlesCannotPin 覆盖 SECURITY_TODO #31:普通作者经由 /api/my/articles +// 提交 is_top=true 时落库为 false;admin 路径不受影响;作者编辑已置顶文章 +// 不会丢失管理员授权的置顶状态。 +func TestMyArticlesCannotPin(t *testing.T) { + e := newSecurityTestEnv(t) + + // 作者创建:is_top=true 被降级为 false。 + alice := e.login(t, "alice") + aliceToken := e.csrfTokenFor(t, alice) + w := postJSON(e, http.MethodPost, "/api/my/articles", alice, aliceToken, gin.H{ + "title": "pinned attempt", "content": "body", "slug": "pinned-attempt", + "status": "1", "is_top": true, + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("author create: status = %d, body %s", w.Code, w.Body.String()) + } + var art models.Article + if err := e.db.Where("slug = ?", "pinned-attempt").First(&art).Error; err != nil { + t.Fatalf("load created article: %v", err) + } + if art.IsTop { + t.Fatal("author-created article must not be pinned") + } + + // 作者编辑:提交 is_top=true 不生效(保持 false)。 + w = postJSON(e, http.MethodPut, "/api/my/articles/"+itoa(art.ID), alice, aliceToken, gin.H{ + "title": "pinned attempt", "content": "body v2", "slug": "pinned-attempt", + "status": "1", "is_top": true, + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("author update: status = %d, body %s", w.Code, w.Body.String()) + } + if err := e.db.First(&art, art.ID).Error; err != nil { + t.Fatalf("reload article: %v", err) + } + if art.IsTop { + t.Fatal("author update must not pin the article") + } + + // admin 路径不受影响。 + admin := e.login(t, "admin") + adminToken := e.csrfTokenFor(t, admin) + w = postJSON(e, http.MethodPut, "/api/admin/articles/"+itoa(art.ID), admin, adminToken, gin.H{ + "title": "pinned attempt", "content": "body v2", "slug": "pinned-attempt", + "status": "1", "is_top": true, + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("admin update: status = %d, body %s", w.Code, w.Body.String()) + } + if err := e.db.First(&art, art.ID).Error; err != nil { + t.Fatalf("reload article after admin pin: %v", err) + } + if !art.IsTop { + t.Fatal("admin path must still be able to pin") + } + + // 管理员授权置顶后:作者编辑保留置顶状态(不会丢失,也不能取消)。 + w = postJSON(e, http.MethodPut, "/api/my/articles/"+itoa(art.ID), alice, aliceToken, gin.H{ + "title": "pinned attempt", "content": "body v3", "slug": "pinned-attempt", + "status": "1", "is_top": false, + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("author update on pinned article: status = %d, body %s", w.Code, w.Body.String()) + } + if err := e.db.First(&art, art.ID).Error; err != nil { + t.Fatalf("reload article: %v", err) + } + if !art.IsTop { + t.Fatal("author edit must not clear an admin-granted pin") + } +} diff --git a/templates/user/my_article_form.html b/templates/user/my_article_form.html index f2e9c85..038f859 100644 --- a/templates/user/my_article_form.html +++ b/templates/user/my_article_form.html @@ -64,14 +64,6 @@ - -
- -