fix: SECURITY_TODO #31 普通作者不可置顶全站文章

任意注册作者可经 /api/my/articles 提交 is_top=true 把文章钉在全站
首页最顶端(publishedArticleOrder 为 is_top DESC 优先),属影响公共
展示位的横向越权(确认非设计意图,予收紧)。

- handlers/article.go: 拆出共享实现 articleCreate/articleUpdate,
  增加 allowIsTop 开关;admin 路径保持 true
- handlers/my_articles.go: 作者创建强制 is_top=false;编辑保留库中
  现有值(管理员授权的置顶不因作者编辑而丢失,作者也无法自行取消)
- templates/user/my_article_form.html: 移除置顶复选框
- 测试: TestMyArticlesCannotPin(作者 create/update is_top=true →
  落库 false;admin 路径可置顶;作者编辑不丢置顶)
This commit is contained in:
dsh
2026-08-27 21:46:27 +08:00
parent f39f0f4320
commit 7e6dd0cc02
5 changed files with 113 additions and 23 deletions
+5 -5
View File
@@ -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 → 落库 falseadmin 路径不受影响
- **修复**(确认非设计意图,予收紧:
- [x] MyArticleCreate/MyArticleUpdate 经共享实现 `articleCreate/articleUpdate(..., allowIsTop=false)` 强制 `is_top=false`MyArticleUpdate 保留库中现有值(管理员授权的置顶不因作者编辑而丢失/取消
- [x] my_article_form.html 移除置顶复选框(作者表单不再提供该字段)
- **验证**: `TestMyArticlesCannotPin`(作者 create/update 提交 is_top=true → 落库 falseadmin 路径可正常置顶;作者编辑已置顶文章不丢失置顶)
---
+14 -2
View File
@@ -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 时普通作者的置顶请求
// 被降级为 falseSECURITY_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,
}
+12 -8
View File
@@ -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,
}
+82
View File
@@ -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 时落库为 falseadmin 路径不受影响;作者编辑已置顶文章
// 不会丢失管理员授权的置顶状态。
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")
}
}
-8
View File
@@ -64,14 +64,6 @@
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
</select>
</div>
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="is_top" value="1" {{if .FormIsTop}}checked{{end}}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<span class="ml-2 text-sm font-medium text-gray-700">{{index .Tr "article_field_is_top"}}</span>
</label>
</div>
</div>
<div class="flex gap-3">