Merge pull request 'fix: 完成 SECURITY_TODO #27–#32 剩余安全修复(限流/魔数校验/最后管理员事务化/置顶收紧/零碎加固)' (#4) from dsh/go_blog:fix/security-todo-27-32 into main

Reviewed-on: kevin/go_blog#4
This commit is contained in:
2026-08-27 23:22:15 +08:00
19 changed files with 784 additions and 90 deletions
+30 -29
View File
@@ -125,20 +125,21 @@
- [x] 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠"失败回退"掩盖),补 blank import
- **验证**: ✅ `TestAddUploadFileTypeRejectsDangerousExtensions`(6 组危险扩展拒绝 + .md 正常)、`TestUploadAvatarRejectsNonImage`(.html 拒绝 / 图片扩展名包 HTML 拒绝 / 正常 PNG 转存 .jpg)、`TestUpdateProfileAvatarRejectsNonImage`(表单头像同样拒绝 + 正常图片成功)
### [ ] 27. /api/auth/register 无速率限制——批量注册垃圾账户(2026-08-27 API 化复审新发现)
### [x] 27. /api/auth/register 无速率限制——批量注册垃圾账户(2026-08-27 API 化复审新发现)✅ 2026-08-27
- **位置**: `handlers/auth.go`Register)、`handlers/login_ratelimit.go`
- **问题**: 登录有限流器(#10IP+用户名),注册完全没有。`allow_registration` 开启时机器人可无限批量注册用户;用户名唯一性检查与 bcrypt cost 12 都不构成成本屏障(注册不触发任何限流计数)。
- **修复**:
- [ ] 复用 LoginRateLimiter 思路,按 IP 限流注册(建议 10 次/小时/IP,键前缀区分 login/register,超限 429 + i18n 新增 `register_locked`
- **验证**: [ ] 测试:同 IP 连续注册超阈值 → 429;其他 IP 不受影响;正常注册成功
- [x] 新增 `handlers/rate_limit.go` `WindowRateLimiter`:固定窗口计数限流器(与 #10 相同的有界 map + 淘汰策略,键前缀区分 login/register
- [x] Register 按 IP 限流注册(10 次/小时/IP),超限 429 + i18n 新增 `register_locked`(中英)
- **验证**: ✅ `TestRegisterRateLimited`(同 IP 连续 10 次成功、第 11 次 429/register_locked、其他 IP 不受影响)、`TestWindowLimiterFixedWindow`(窗口内超限拒绝 / 窗口翻转重置 / 键隔离)
### [ ] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现)
### [x] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现)✅ 2026-08-27
- **位置**: `handlers/comment.go`PostComment)、`models/seed.go`(默认策略)
- **问题**: 未认证即可提交评论(默认 `AllowGuest=true`),且无任何频率限制;配合默认 `GuestRequireApproval=false`(即时公开显示),开箱即用状态可被灌水机刷屏,同时放大 #26 的攻击面。
- **修复**:
- [ ] 按 IP(或 IP+文章)限流评论提交(建议 5 条/分钟/IP),超限 429 + i18n 新增 `comments_locked`
- [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true`
- **验证**: [ ] 测试:同 IP 高频提交 → 429;正常节奏不受影响
- [x] PostComment 按 IP 限流评论提交(5 条/分钟/IP,复用 #27`WindowRateLimiter`),超限 429 + i18n 新增 `comments_locked`(中英)
- [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true`——留待产品确认,未随本项实施
- **验证**: `TestCommentRateLimited`(同 IP 5 次成功、第 6 次 429/comments_locked、其他 IP 不受影响
---
@@ -193,27 +194,28 @@
- **修复**: 新增公共 `validateEmail`(空值放行,非空走 `net/mail.ParseAddress`,与评论处口径一致),四处统一调用
- **验证**: ✅ `TestRegisterRejectsInvalidEmail``TestProfileEmailValidation``TestAdminUserPasswordAndEmailEnforcement``abc` 均拒绝、合法邮箱正常)
### [ ] 29. 站点 favicon/logo 上传缺魔数校验(2026-08-27 API 化复审新发现)
### [x] 29. 站点 favicon/logo 上传缺魔数校验(2026-08-27 API 化复审新发现)✅ 2026-08-27
- **位置**: `handlers/settings.go`saveSiteImage
- **问题**: 头像上传(profile.go)与附件上传(attachment.go)均调用 `contentMatchesType` 做魔数一致性校验(#14/#21),但 saveSiteImage 只查扩展名白名单 + Category=image 即 `io.Copy` 落盘——管理员可把 HTML 内容存为 `logos/logo.png`。当前由 `X-Content-Type-Options: nosniff` + 按扩展名的 Content-Type 兜底(浏览器不会执行),但纵深防御链条在此断裂。
- **修复**:
- [ ] saveSiteImage 读取字节后调用 `contentMatchesType(check.Type, content)`,不匹配返回 400(与头像上传口径一致)
- **验证**: [ ] 测试PNG 扩展名 + HTML 字节 → 400;正常 PNG → 200
- [x] saveSiteImage 读取字节后调用 `contentMatchesType(check.Type, content)`,不匹配返回 400`settings_upload_bad_content`i18n 中英新增;与头像上传口径一致)
- **验证**: `TestSiteImageUploadRejectsMismatchedContent`favicon/logo 各PNG 扩展名 + HTML 字节 → 400;正常 PNG → 200
### [ ] 30. 最后管理员防线存在 TOCTOU 竞态(2026-08-27 API 化复审新发现)
### [x] 30. 最后管理员防线存在 TOCTOU 竞态(2026-08-27 API 化复审新发现)✅ 2026-08-27
- **位置**: `handlers/admin_user.go`UserUpdate 降级检查、UserDelete 删除检查)
- **问题**: `adminCount <= 1` 检查与后续 Save/Delete 非原子:两个并发的"降级/删除最后一位管理员"请求可同时通过检查,导致站点失去管理员。SQLite 单写锁下窗口极小;MySQL 部署是真实窗口(需管理员 CSRF 或双开标签配合,可利用性低)。
- **修复**:
- [ ] 检查+写入包进 `db.Transaction`事务内先计数再更新(MySQL 下依赖行锁或 `SELECT ... FOR UPDATE`
- **验证**: [ ] 测试:并发降级最后管理员的请求,最终至少保留一个 admin(`-race`
- [x] 检查+写入包进 `db.Transaction`事务内 `ensureNotLastAdmin` 先锁定管理员集合(MySQL`SELECT ... FOR UPDATE`GORM `clause.Locking`)再计数,并发事务串行化后重读
- [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 路径可正常置顶;作者编辑已置顶文章不丢失置顶)
---
@@ -247,13 +249,13 @@
- [x] 用户不存在时也执行一次 dummy bcrypt 比较(包级预生成哑哈希),抹平时间差;两分支均记录失败计数
- **验证**: ✅ 结构保证两分支均执行一次 bcrypt(`TestLoginTimingDoesNotRevealUser` 断言未知用户分支进入 Fail);大样本计时统计属人工运维验证,逻辑上两分支 B 树一致
### [ ] 32. 零碎加固(2026-08-27 API 化复审新发现)
### [x] 32. 零碎加固(2026-08-27 API 化复审新发现)✅ 2026-08-27
- **位置**: 多处
- **问题与修复**:
- [ ] `admin_user.go` UserUpdate`status` 无枚举校验,可存任意 int(如 99)——限定 {0,1,2,3},非法 400
- [ ] `attachment.go` parseUintParam / parseUintForm`Sscanf("%d")` 会把 `"5abc"` 宽松解析为 5——改 `strconv.ParseUint` 严格拒绝(无注入风险,值已为数值类型,仅严谨性)
- [ ] `settings.go` dangerousUploadExtensions:补充 `.xsl` / `.xslt` / `.shtml`nosniff 已兜底,仅完整性)
- **验证**: [ ] 表驱动测试:非法 status → 400;`"5abc"` 形式的 id → 拒绝
- [x] `admin_user.go` UserCreate/UserUpdate`status` 无枚举校验,可存任意 int(如 99)——新增 `validUserStatus` 限定 {0,1,2,3},非法 400i18n `user_status_invalid` 中英新增)
- [x] `attachment.go` parseUintParam / parseUintForm`Sscanf("%d")` 会把 `"5abc"` 宽松解析为 5——改 `strconv.ParseUint` 严格拒绝(无注入风险,值已为数值类型,仅严谨性)
- [x] `settings.go` dangerousUploadExtensions:补充 `.xsl` / `.xslt` / `.shtml`nosniff 已兜底,仅完整性)
- **验证**: `TestUserStatusEnumRejected`(-1/99 → 400 且数据不变;锁定/禁用/正常逐一生效)、`TestParseUintStrict`(路由参数与表单字段的 "5abc"/溢出值拒绝,合法值正常)、`TestAddUploadFileTypeRejectsDangerousExtensions` 扩展用例(.xsl/.xslt/.shtml 拒绝 + 良性 .md 通过)
---
@@ -274,13 +276,12 @@
## 建议执行顺序
#1–#25 及 #26 已修复并验证。剩余待办 #27–#32,建议顺序:
**#1#32 全部修复并验证完毕(2026-08-27)。** 实施顺序:
1. ~~#26 请求体大小限制~~ ✅ 2026-08-27
2. **#27/#28 注册与评论限流**P1,可与 #26 的中间件基建衔接实施
3. **#29 favicon/logo 魔数校验**、**#30 最后管理员事务化**P2各自独立小改)
4. **#31 置顶权限**需先确认产品意图(作者可置顶是否预期)再决定修否
5. **#32 零碎项**随手修
2. ~~#27/#28 注册与评论限流~~ ✅ 2026-08-27(新增 `handlers/rate_limit.go` 固定窗口限流器,可与 #26 的中间件基建衔接)
3. ~~#29 favicon/logo 魔数校验~~、~~#30 最后管理员事务化~~ ✅ 2026-08-27各自独立小改)
4. ~~#31 置顶权限~~ ✅ 2026-08-27(确认"作者可置顶"非产品预期,按收紧方案实施:作者 create/update 忽略 is_top,表单移除复选框;管理员授权置顶不因作者编辑丢失)
5. ~~#32 零碎项~~ ✅ 2026-08-27status 枚举校验 / 严格 uint 解析 / 危险扩展名补充)
历史遗留观察项(不阻塞):
+92 -17
View File
@@ -1,16 +1,19 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// userForm 是后台用户创建/更新接口的 JSON 请求体,
@@ -69,6 +72,47 @@ func uintFormID(s string) uint {
return uint(n)
}
// validUserStatus 报告后台用户表单提交的状态值是否属于枚举
// {StatusDisabled(0), StatusNormal(1), StatusLocked(2), StatusUnactivated(3)}
// SECURITY_TODO #32)。此前 status 无枚举校验,可写入任意 int
// (如 99),产生界面无法解释的状态。
func validUserStatus(s int) bool {
switch s {
case models.StatusDisabled, models.StatusNormal, models.StatusLocked, models.StatusUnactivated:
return true
}
return false
}
// errLastAdminRemoval 与 lastAdminMu 实现"最后管理员"防线
// SECURITY_TODO #30)。原实现先计数再写入,两者之间无原子性:
// 两个并发的"降级/删除倒数第二位管理员"请求可同时通过检查,
// 导致站点失去管理员。修复后检查与写入包进同一事务:
// - MySQL:事务内 SELECT ... FOR UPDATE 锁定管理员集合,
// 并发事务在锁上排队,先提交者生效,后到者重读计数后拒绝;
// - SQLite:不支持 FOR UPDATE,但其写锁串行化 + 本进程互斥锁
// (应用按设计单实例部署,见 LoginRateLimiter 注释)双保险。
var errLastAdminRemoval = errors.New("cannot remove the last admin")
var lastAdminMu sync.Mutex
// ensureNotLastAdmin 在事务内读取管理员数量(MySQL 下为锁定读取)。
// 仅剩 1 位(或 0 位)管理员时返回 errLastAdminRemoval。
func ensureNotLastAdmin(tx *gorm.DB) error {
q := tx.Model(&models.User{}).Where("role = ?", models.RoleAdmin)
if tx.Dialector.Name() != "sqlite" {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var admins []models.User
if err := q.Find(&admins).Error; err != nil {
return err
}
if len(admins) <= 1 {
return errLastAdminRemoval
}
return nil
}
// applyUserFormToData 将表单值写入模板数据映射,
// 使渲染时表单被重新填充(初次加载或校验错误)。
func applyUserFormToData(data gin.H, f userForm) {
@@ -198,6 +242,11 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc {
APIError(c, http.StatusBadRequest, "user_username_required")
return
}
// SECURITY_TODO #32status 必须属于枚举 {0,1,2,3}。
if !validUserStatus(f.Status) {
APIError(c, http.StatusBadRequest, "user_status_invalid")
return
}
if f.Password == "" {
APIError(c, http.StatusBadRequest, "user_password_required")
return
@@ -296,6 +345,11 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
// SECURITY_TODO #32status 必须属于枚举 {0,1,2,3}。
if !validUserStatus(f.Status) {
APIError(c, http.StatusBadRequest, "user_status_invalid")
return
}
var user models.User
if err := db.First(&user, f.ID).Error; err != nil {
@@ -320,21 +374,15 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
currentID := userIDFromSession(c)
isSelf := user.ID == currentID
// 最后管理员检查必须在 user.Role 被新值覆盖之前基于原值判定。
wasAdmin := user.Role == models.RoleAdmin
// 自我保护:不能禁用/锁定自己的账户。
if isSelf && f.Status != models.StatusNormal {
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
return
}
// 自我保护:不能降级最后一位管理员。
if user.Role == models.RoleAdmin && f.Role != models.RoleAdmin {
var adminCount int64
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
if adminCount <= 1 {
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
}
if f.Role == "" {
f.Role = user.Role
}
@@ -358,7 +406,24 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
}
}
if err := db.Save(&user).Error; err != nil {
// 自我保护:不能降级最后一位管理员(SECURITY_TODO #30
// 检查与写入在事务内原子执行,消除 TOCTOU 竞态)。
lastAdminCheck := wasAdmin && f.Role != models.RoleAdmin
lastAdminMu.Lock()
err := db.Transaction(func(tx *gorm.DB) error {
if lastAdminCheck {
if err := ensureNotLastAdmin(tx); err != nil {
return err
}
}
return tx.Save(&user).Error
})
lastAdminMu.Unlock()
if err != nil {
if errors.Is(err, errLastAdminRemoval) {
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
APIError(c, http.StatusInternalServerError, "article_error")
return
}
@@ -388,17 +453,27 @@ func UserDelete(db *gorm.DB) gin.HandlerFunc {
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
return
}
// 不能删除最后一位管理员
if user.Role == models.RoleAdmin {
var adminCount int64
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
if adminCount <= 1 {
// 不能删除最后一位管理员SECURITY_TODO #30
// 检查与写入在事务内原子执行,消除 TOCTOU 竞态)。
lastAdminCheck := user.Role == models.RoleAdmin
lastAdminMu.Lock()
err := db.Transaction(func(tx *gorm.DB) error {
if lastAdminCheck {
if err := ensureNotLastAdmin(tx); err != nil {
return err
}
}
return tx.Delete(&user).Error
})
lastAdminMu.Unlock()
if err != nil {
if errors.Is(err, errLastAdminRemoval) {
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
APIError(c, http.StatusInternalServerError, "article_error")
return
}
db.Delete(&user)
APIOK(c, "/admin/users?saved=1&msg=deleted", nil)
}
}
+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,
}
+19 -12
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -270,20 +271,26 @@ func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
// ---------------- 辅助函数 ----------------
// parseUintForm 解析 uint 表单字段,容忍空/非法输入。
func parseUintForm(c *gin.Context, field string) uint {
v := strings.TrimSpace(c.PostForm(field))
if v == "" {
// parseUintStrict 严格解析十进制 uint:空值、非数字、前缀数字("5abc"
// 与超出范围的值一律返回 0SECURITY_TODO #32——旧的 Sscanf("%d") 会把
// "5abc" 宽松解析为 5,掩盖非法输入)。
func parseUintStrict(s string) uint {
if s == "" {
return 0
}
var n uint
_, _ = fmt.Sscanf(v, "%d", &n)
return n
n, err := strconv.ParseUint(s, 10, 64)
if err != nil || n > uint64(^uint(0)) {
return 0
}
return uint(n)
}
// parseUintParam 解析 uint 路由参数
func parseUintParam(c *gin.Context, name string) uint {
var n uint
_, _ = fmt.Sscanf(c.Param(name), "%d", &n)
return n
// parseUintForm 解析 uint 表单字段(严格;空/非法输入返回 0
func parseUintForm(c *gin.Context, field string) uint {
return parseUintStrict(strings.TrimSpace(c.PostForm(field)))
}
// parseUintParam 解析 uint 路由参数(严格;空/非法输入返回 0)。
func parseUintParam(c *gin.Context, name string) uint {
return parseUintStrict(c.Param(name))
}
+10 -2
View File
@@ -152,9 +152,17 @@ type registerRequest struct {
DisplayName string `json:"display_name"`
}
// Register 处理注册表单提交。
func Register(db *gorm.DB) gin.HandlerFunc {
// Register 处理注册表单提交。它对每个 IP 实施速率限制
// SECURITY_TODO #27),防止批量注册垃圾账户。
func Register(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
// SECURITY_TODO #27:按 IP 限流注册(10 次/小时),
// 键加前缀与登录限流器区分。超限直接 429,不执行任何数据库工作。
if !limiter.Allow("register\x00" + GetClientIP(c)) {
APIError(c, http.StatusTooManyRequests, "register_locked")
return
}
// 检查是否允许注册
var s models.SiteSetting
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
+8 -1
View File
@@ -90,8 +90,15 @@ func emailHash(email string) string {
}
// PostComment 处理在文章上提交新评论(或回复)。
func PostComment(db *gorm.DB) gin.HandlerFunc {
// 它对每个 IP 实施速率限制(SECURITY_TODO #28),防止灌水机刷屏。
func PostComment(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
// SECURITY_TODO #28:按 IP 限流评论提交(5 条/分钟),超限 429。
if !limiter.Allow("comment\x00" + GetClientIP(c)) {
APIError(c, http.StatusTooManyRequests, "comments_locked")
return
}
slug := c.Param("slug")
var article models.Article
+82
View File
@@ -0,0 +1,82 @@
package handlers
import (
"fmt"
"net/http"
"sync"
"testing"
"github.com/gin-gonic/gin"
"go_blog/models"
)
// TestConcurrentLastAdminDowngrade 覆盖 SECURITY_TODO #30:两个并发的
// "降级倒数第二位管理员"请求不再能同时通过检查——最终恰好一位管理员
// 被降级、另一位被拒绝,且站点至少保留一位管理员。旧实现(计数与写入
// 非原子)下两个请求都会成功,管理员清零(变异测试可验证)。
func TestConcurrentLastAdminDowngrade(t *testing.T) {
e := newSecurityTestEnv(t)
// 增加第二位管理员(admin2);alice/bob 保持 author。
admin2 := mustUser(t, e.db, "admin2", models.RoleAdmin)
var admin1 models.User
if err := e.db.Where("username = ?", "admin").First(&admin1).Error; err != nil {
t.Fatalf("load admin1: %v", err)
}
// 两位管理员分别用自己的会话并发发起"降级自己"的请求。
session1 := e.login(t, "admin")
token1 := e.csrfTokenFor(t, session1)
session2 := e.login(t, "admin2")
token2 := e.csrfTokenFor(t, session2)
start := make(chan struct{})
var wg sync.WaitGroup
codes := make(chan int, 2)
requests := []struct {
id uint
cookie string
token string
}{
{admin1.ID, session1, token1},
{admin2.ID, session2, token2},
}
for _, req := range requests {
wg.Add(1)
go func(id uint, cookie, token string) {
defer wg.Done()
<-start
w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", id), cookie, token,
gin.H{"role": models.RoleAuthor, "status": models.StatusNormal})
codes <- w.Code
}(req.id, req.cookie, req.token)
}
close(start)
wg.Wait()
close(codes)
var success, rejected int
for code := range codes {
switch code {
case http.StatusOK:
success++
case http.StatusForbidden:
rejected++
default:
t.Fatalf("unexpected status %d (body-level check skipped; want 200 or 403)", code)
}
}
if success != 1 || rejected != 1 {
t.Fatalf("concurrent demotions: success=%d rejected=%d, want exactly 1/1", success, rejected)
}
// 最终至少保留一位管理员。
var count int64
if err := e.db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&count).Error; err != nil {
t.Fatalf("count admins: %v", err)
}
if count < 1 {
t.Fatal("no admin remains after concurrent demotions")
}
}
+92
View File
@@ -0,0 +1,92 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go_blog/models"
)
// TestUserStatusEnumRejected 覆盖 SECURITY_TODO #32 第一项:
// UserCreate/UserUpdate 的 status 仅接受枚举 {0,1,2,3},非法值返回 400
// 且数据不变;合法枚举值正常生效。
func TestUserStatusEnumRejected(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("load alice: %v", err)
}
for _, bad := range []int{-1, 99} {
w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", alice.ID), admin, token,
gin.H{"username": "alice", "email": "alice@example.com",
"role": models.RoleAuthor, "status": bad})
if w.Code != http.StatusBadRequest || respCode(w) != "user_status_invalid" {
t.Fatalf("status %d: code = %d/%q, want 400/user_status_invalid", bad, w.Code, respCode(w))
}
}
var reloaded models.User
if err := e.db.First(&reloaded, alice.ID).Error; err != nil {
t.Fatalf("reload alice: %v", err)
}
if reloaded.Status != models.StatusNormal {
t.Fatalf("invalid status persisted: status = %d", reloaded.Status)
}
// 合法枚举值(锁定 → 禁用 → 恢复正常)逐个生效。
for _, want := range []int{models.StatusLocked, models.StatusDisabled, models.StatusNormal} {
w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", alice.ID), admin, token,
gin.H{"username": "alice", "email": "alice@example.com",
"role": models.RoleAuthor, "status": want})
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("status %d: code = %d, body %s", want, w.Code, w.Body.String())
}
if err := e.db.First(&reloaded, alice.ID).Error; err != nil {
t.Fatalf("reload alice: %v", err)
}
if reloaded.Status != want {
t.Fatalf("status = %d, want %d", reloaded.Status, want)
}
}
}
// TestParseUintStrict 覆盖 SECURITY_TODO #32 第二项:
// parseUintParam/parseUintForm 必须严格拒绝 "5abc" 一类的前缀数字输入
// (旧的 fmt.Sscanf("%d") 会宽松解析为 5)。
func TestParseUintStrict(t *testing.T) {
// 路由参数。
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Params = gin.Params{{Key: "id", Value: "5abc"}}
if got := parseUintParam(c, "id"); got != 0 {
t.Fatalf("parseUintParam(5abc) = %d, want 0", got)
}
c.Params = gin.Params{{Key: "id", Value: "42"}}
if got := parseUintParam(c, "id"); got != 42 {
t.Fatalf("parseUintParam(42) = %d, want 42", got)
}
c.Params = gin.Params{{Key: "id", Value: "18446744073709551616"}} // > uint64
if got := parseUintParam(c, "id"); got != 0 {
t.Fatalf("parseUintParam(overflow) = %d, want 0", got)
}
// 表单字段(每个用例独立 context,避免 gin 的 form 缓存干扰)。
formCtx := func(body string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return c
}
if got := parseUintForm(formCtx("article_id=5abc"), "article_id"); got != 0 {
t.Fatalf("parseUintForm(article_id=5abc) = %d, want 0", got)
}
if got := parseUintForm(formCtx("article_id=7"), "article_id"); got != 7 {
t.Fatalf("parseUintForm(article_id=7) = %d, want 7", got)
}
}
+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")
}
}
+98
View File
@@ -0,0 +1,98 @@
package handlers
import (
"sync"
"time"
)
// 注册/评论的固定窗口限流参数(SECURITY_TODO #27/#28)。
// 与 LoginRateLimiter 一样为进程内存实现:应用是单实例部署
// (反向代理后的 unix socket),因此无需共享存储。
const (
registerLimitPerHour = 10 // 每 IP 每小时的注册上限
registerWindow = time.Hour // 注册计数窗口
commentLimitPerMin = 5 // 每 IP 每分钟的评论上限
commentWindow = time.Minute // 评论计数窗口
)
// WindowRateLimiter 是固定时间窗口计数限流器:键在窗口内最多计数 limit 次,
// 超限时 Allow 返回 false。窗口过期后计数自动重置。
type WindowRateLimiter struct {
mu sync.Mutex
entries map[string]*windowRateEntry
limit int
window time.Duration
nowFn func() time.Time
}
type windowRateEntry struct {
count int
windowStart time.Time
lastSeen time.Time
}
// NewWindowLimiter 创建固定窗口限流器(limit 次/每 window)。
func NewWindowLimiter(limit int, window time.Duration) *WindowRateLimiter {
return &WindowRateLimiter{
entries: make(map[string]*windowRateEntry),
limit: limit,
window: window,
nowFn: time.Now,
}
}
// Allow 报告该键是否被允许再发一次请求(调用本身计入窗口计数;
// 窗口过期时计数重置为 1)。
func (l *WindowRateLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.nowFn()
e, ok := l.entries[key]
if !ok || now.Sub(e.windowStart) >= l.window {
// 新窗口:重置计数。
l.entries[key] = &windowRateEntry{count: 1, windowStart: now, lastSeen: now}
l.sweep(now)
return true
}
e.count++
e.lastSeen = now
l.sweep(now)
return e.count <= l.limit
}
// sweep 限制映射大小,防止攻击者通过大量键令限流器无限增长
// (与 LoginRateLimiter.sweep 同一策略)。
func (l *WindowRateLimiter) sweep(now time.Time) {
if len(l.entries) <= maxTrackedKeys {
return
}
// 第 1 轮:移除窗口已过且不再活跃的键。
for k, e := range l.entries {
if now.Sub(e.lastSeen) > l.window {
delete(l.entries, k)
}
}
if len(l.entries) <= maxTrackedKeys {
return
}
// 第 2 轮:若仍然过大,按 lastSeen 逐出最旧的条目。
cut := len(l.entries) - maxTrackedKeys + maxTrackedKeys/4
var byOldest []struct {
key string
t time.Time
}
for k, e := range l.entries {
byOldest = append(byOldest, struct {
key string
t time.Time
}{k, e.lastSeen})
}
for i := 1; i < len(byOldest); i++ {
for j := i; j > 0 && byOldest[j].t.Before(byOldest[j-1].t); j-- {
byOldest[j], byOldest[j-1] = byOldest[j-1], byOldest[j]
}
}
for _, o := range byOldest[:cut] {
delete(l.entries, o.key)
}
}
+152
View File
@@ -0,0 +1,152 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"go_blog/models"
)
// TestWindowLimiterFixedWindow 单元测试固定窗口行为:窗口内超限拒绝、
// 窗口过期后计数重置、不同键互不影响、键有界(sweep 生效)。
func TestWindowLimiterFixedWindow(t *testing.T) {
l := NewWindowLimiter(2, time.Minute)
now := time.Unix(1_000_000, 0)
l.nowFn = func() time.Time { return now }
for i := 0; i < 2; i++ {
if !l.Allow("a") {
t.Fatalf("attempt %d: expect allowed within limit", i+1)
}
}
if l.Allow("a") {
t.Fatal("expect blocked after limit")
}
// 其他键不受影响。
if !l.Allow("b") {
t.Fatal("different key must not be affected")
}
// 窗口过期后计数重置。
now = now.Add(time.Minute + time.Second)
if !l.Allow("a") {
t.Fatal("expect allowed after window rollover")
}
}
// guestSessionAndToken 取一个匿名会话及其 CSRF 令牌。
func guestSessionAndToken(e *securityTestEnv) (string, string) {
w := e.do(http.MethodGet, "/login", "", nil, "")
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
return e.sessionCookie(w), ""
}
return e.sessionCookie(w), m[1]
}
// postJSONFrom 与 postJSON 相同,但可指定客户端 RemoteAddr 以模拟不同来源 IP。
func postJSONFrom(e *securityTestEnv, method, path, cookie, csrfToken, ip string, body interface{}) *httptest.ResponseRecorder {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(body)
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)
}
req.RemoteAddr = ip + ":4321"
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
return w
}
// TestRegisterRateLimited 覆盖 SECURITY_TODO #27:同 IP 连续注册超过
// 阈值(10 次/小时)后返回 429/register_locked;其他 IP 不受影响。
func TestRegisterRateLimited(t *testing.T) {
e := newSecurityTestEnv(t)
if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil {
t.Fatalf("enable registration: %v", err)
}
register := func(username, ip string) *httptest.ResponseRecorder {
cookie, token := guestSessionAndToken(e)
if token == "" {
t.Fatal("login page did not render a CSRF token")
}
return postJSONFrom(e, http.MethodPost, "/api/auth/register", cookie, token, ip, gin.H{
"username": username,
"password": "secret1",
"confirm_password": "secret1",
"email": username + "@example.com",
})
}
const ipA = "198.51.100.10"
for i := 0; i < registerLimitPerHour; i++ {
w := register(fmt.Sprintf("reg%d", i), ipA)
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("attempt %d: status = %d, body %s", i+1, w.Code, w.Body.String())
}
}
// 下一次尝试(即使输入合法)被限流。
w := register("reg-over", ipA)
if w.Code != http.StatusTooManyRequests || respCode(w) != "register_locked" {
t.Fatalf("rate-limited register: status = %d, code = %q, want 429/register_locked",
w.Code, respCode(w))
}
// 其他 IP 不受影响。
w = register("reg-other", "198.51.100.11")
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("register from other IP: status = %d, body %s", w.Code, w.Body.String())
}
}
// TestCommentRateLimited 覆盖 SECURITY_TODO #28:同 IP 高频提交评论超过
// 阈值(5 条/分钟)后返回 429/comments_locked;其他 IP 不受影响。
func TestCommentRateLimited(t *testing.T) {
e := newSecurityTestEnv(t)
comment := func(ip string) *httptest.ResponseRecorder {
cookie, token := guestSessionAndToken(e)
if token == "" {
t.Fatal("login page did not render a CSRF token")
}
return postJSONFrom(e, http.MethodPost, "/api/article/alice-post/comments", cookie, token, ip, gin.H{
"name": "guest",
"email": "guest@example.com",
"content": "nice post",
})
}
const ipA = "198.51.100.20"
for i := 0; i < commentLimitPerMin; i++ {
w := comment(ipA)
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("comment %d: status = %d, body %s", i+1, w.Code, w.Body.String())
}
}
// 第六次提交被限流。
w := comment(ipA)
if w.Code != http.StatusTooManyRequests || respCode(w) != "comments_locked" {
t.Fatalf("rate-limited comment: status = %d, code = %q, want 429/comments_locked",
w.Code, respCode(w))
}
// 其他 IP 不受影响。
w = comment("198.51.100.21")
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("comment from other IP: status = %d, body %s", w.Code, w.Body.String())
}
}
+15 -7
View File
@@ -29,10 +29,12 @@ import (
// securityTestEnv 搭建与生产中间件链一致的路由器
// sessions -> CSRF -> 用户上下文),外加待测路由。
type securityTestEnv struct {
router *gin.Engine
db *gorm.DB
storageDir string
limiter *LoginRateLimiter
router *gin.Engine
db *gorm.DB
storageDir string
limiter *LoginRateLimiter
registerLimiter *WindowRateLimiter
commentLimiter *WindowRateLimiter
}
var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`)
@@ -78,6 +80,8 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
r.LoadHTMLGlob("../templates/**/*.html")
store := cookie.NewStore([]byte("test-secret"))
limiter := NewLoginLimiter()
registerLimiter := NewWindowLimiter(registerLimitPerHour, registerWindow)
commentLimiter := NewWindowLimiter(commentLimitPerMin, commentWindow)
r.Use(sessions.Sessions("blog_session", store))
r.Use(middleware.SetUserContext(db))
r.Use(middleware.BodyLimit())
@@ -91,8 +95,8 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
{
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))
api.POST("/auth/register", Register(db, registerLimiter))
api.POST("/article/:slug/comments", PostComment(db, commentLimiter))
}
protected := r.Group("/my", middleware.AuthRequired(db))
@@ -124,6 +128,9 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
adminSettingsAPI := r.Group("/api/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db))
{
adminSettingsAPI.POST("/upload", UploadSettingsSave(db))
// 站点 favicon/logo 上传(#29 魔数校验覆盖)。
adminSettingsAPI.POST("/site/favicon", SiteFaviconUpload(db, storageDir))
adminSettingsAPI.POST("/site/logo", SiteLogoUpload(db, storageDir))
}
// 后台用户管理路由(SQL 注入回归覆盖,#19)。
@@ -147,7 +154,8 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
articleAPI.DELETE("/:id", ArticleDelete(db, "/admin/articles"))
}
return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter}
return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter,
registerLimiter: registerLimiter, commentLimiter: commentLimiter}
}
func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User {
+2 -1
View File
@@ -159,7 +159,8 @@ func TestAddUploadFileTypeRejectsDangerousExtensions(t *testing.T) {
admin := e.login(t, "admin")
token := e.csrfTokenFor(t, admin)
for _, ext := range []string{"html", ".htm", "SVG", "xhtml", ".xml", "js"} {
// SECURITY_TODO #32:黑名单补充 .xsl/.xslt/.shtml 后并入同一用例。
for _, ext := range []string{"html", ".htm", "SVG", "xhtml", ".xml", "js", ".xsl", ".xslt", ".shtml"} {
w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token,
gin.H{"action": "add_type", "extension": ext, "category": models.CategoryImage})
if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_dangerous_ext" {
+18 -1
View File
@@ -201,6 +201,20 @@ func saveSiteImage(db *gorm.DB, storagePath, fieldName, prefix string) gin.Handl
return
}
// SECURITY_TODO #29:与头像/附件上传一致,做魔数一致性校验——
// 管理员不得把 HTML 字节另存为 .png 等图片扩展名。当前靠
// X-Content-Type-Options: nosniff + 按扩展名的 Content-Type 兜底,
// 纵深防御链条在此补齐。
content, err := io.ReadAll(file)
if err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
if !contentMatchesType(check.Type, content) {
APIError(c, http.StatusBadRequest, "settings_upload_bad_content")
return
}
logoDir := filepath.Join(storagePath, "logos")
os.MkdirAll(logoDir, 0755)
// 删除之前的本地文件(跳过外部 URL)。
@@ -219,7 +233,7 @@ func saveSiteImage(db *gorm.DB, storagePath, fieldName, prefix string) gin.Handl
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
if _, err := dst.Write(content); err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
@@ -384,6 +398,9 @@ func saveUploadConfig(db *gorm.DB, req uploadSettingsRequest, updatedBy uint) bo
var dangerousUploadExtensions = map[string]bool{
".html": true, ".htm": true, ".xhtml": true, ".xht": true,
".svg": true, ".xml": true, ".js": true, ".mjs": true,
// SECURITY_TODO #32:补充服务器端处理型扩展名(XSLT 可内嵌脚本、
// SSI 可包含文件),nosniff 已兜底,此处仅完整性。
".xsl": true, ".xslt": true, ".shtml": true,
}
// addUploadFileType 创建新的允许文件类型。报告扩展名是否因危险而被拒绝。
+42
View File
@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"testing"
"go_blog/models"
)
// TestSiteImageUploadRejectsMismatchedContent 覆盖 SECURITY_TODO #29
// favicon/logo 上传必须通过魔数一致性校验——PNG 扩展名携带 HTML 字节
// 返回 400 且不落盘;真实 PNG 成功保存。
func TestSiteImageUploadRejectsMismatchedContent(t *testing.T) {
e := newSecurityTestEnv(t)
seedUploadType(t, e, ".png", models.CategoryImage)
// 配置图片 MIME 策略,使内容校验有据可依(种子行的策略为空值,宽容放行)。
e.db.Model(&models.UploadFileType{}).Where("extension = ?", ".png").Update("mime_type", "image/png")
models.LoadConfigCache(e.db)
admin := e.login(t, "admin")
token := e.csrfTokenFor(t, admin)
for _, endpoint := range []struct{ path, field string }{
{"/api/admin/settings/site/favicon", "favicon"},
{"/api/admin/settings/site/logo", "logo"},
} {
// .png 扩展名 + HTML 字节 → 400(内容与声明类型不匹配)。
w := e.multipartUpload(t, endpoint.path, admin, token, endpoint.field,
"logo.png", []byte("<html><script>alert(1)</script></html>"), nil)
if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_bad_content" {
t.Fatalf("%s mismatched content: status = %d, code = %q, want 400/settings_upload_bad_content",
endpoint.path, w.Code, respCode(w))
}
// 真实 PNG → 200。
w = e.multipartUpload(t, endpoint.path, admin, token, endpoint.field,
"logo.png", pngBytes(t), nil)
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("%s valid png: status = %d, body %s", endpoint.path, w.Code, w.Body.String())
}
}
}
+8
View File
@@ -451,6 +451,10 @@ var translations = map[Lang]map[string]string{
"api_forbidden": "You do not have permission to perform this action.",
"api_invalid_request": "Invalid request body.",
"request_too_large": "The request body exceeds the size limit.",
"register_locked": "Too many registration attempts from your address. Please try again later.",
"comments_locked": "Too many comments from your address. Please wait a moment and try again.",
"settings_upload_bad_content": "File content does not match its declared type.",
"user_status_invalid": "Invalid user status.",
"user_not_found": "User not found.",
},
ZH: {
@@ -888,6 +892,10 @@ var translations = map[Lang]map[string]string{
"api_forbidden": "您没有权限执行此操作。",
"api_invalid_request": "请求参数格式不正确。",
"request_too_large": "请求体超过大小限制。",
"register_locked": "来自该地址的注册次数过多,请稍后再试。",
"comments_locked": "评论提交过于频繁,请稍后再试。",
"settings_upload_bad_content": "文件内容与声明类型不匹配。",
"user_status_invalid": "无效的用户状态。",
"user_not_found": "用户不存在。",
},
}
+8 -2
View File
@@ -12,6 +12,7 @@ import (
"path"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
@@ -163,6 +164,11 @@ func main() {
// 签名包含 db 与 loginLimiter,但注册阶段不会触碰它们(handler 是惰性工厂),
// 因此冒烟测试可传 nil。
func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLimiter *handlers.LoginRateLimiter) {
// 注册/评论限流器(SECURITY_TODO #27/#28):固定窗口、进程内存、map 有界。
// 单实例部署无需共享存储。
registerLimiter := handlers.NewWindowLimiter(10, time.Hour)
commentLimiter := handlers.NewWindowLimiter(5, time.Minute)
// 公开页面。
router.GET("/", handlers.HomePage(db))
router.GET("/search", handlers.SearchPage(db))
@@ -177,9 +183,9 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
{
api.GET("/articles", handlers.HomeArticlesAPI(db))
api.POST("/auth/login", handlers.Login(db, loginLimiter))
api.POST("/auth/register", handlers.Register(db))
api.POST("/auth/register", handlers.Register(db, registerLimiter))
api.POST("/auth/logout", handlers.Logout())
api.POST("/article/:slug/comments", handlers.PostComment(db))
api.POST("/article/:slug/comments", handlers.PostComment(db, commentLimiter))
}
// 受保护的后台路由(仅管理员角色)。
-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">