fix: 完成 P0 安全修复 #26——请求体大小限制防未认证内存耗尽 DoS

- 新增 middleware/bodylimit.go:BodyLimit 中间件,非 multipart 统一
  4 MiB(覆盖文章正文上限);multipart 按平台上传策略派生(启用类型
  限制与全局默认取最大 +1 MiB 开销,下限不低于编译期默认)。
  Content-Length 已知且超限读体前直接 413,其余经 MaxBytesReader 截断
- handlers/api.go:bindJSON 识别 *http.MaxBytesError → 413 +
  request_too_large(i18n 中英新增)
- main.go:中间件顺序调整为 SetUserContext → BodyLimit → CSRF——
  必须先于 CSRF(其解析 multipart 会读取整个请求体),SetUserContext
  提前使 413 文案可按请求语言翻译;测试环境链同步(security_test.go)
- middleware/auth.go:apiAuthError 更名 apiError(BodyLimit 复用)
- 新增 handlers/bodylimit_test.go:超限 JSON 两种形态(已知长度/
  chunked)413、正常体放行至认证层、3MB multipart 拒绝且附件表零写入、
  GET 不受影响
- SECURITY_TODO.md:#26 勾选完成并记录验证;API 化复审新增 #26–#32
  待办清单(P1 注册/评论限流、P2 favicon 魔数校验/最后管理员竞态/
  置顶权限、P3 零碎项)与执行顺序

go build / vet / test -race ./... 全绿
This commit is contained in:
2026-08-27 21:23:19 +08:00
parent 0f1dc7969d
commit 2e3888d5b1
8 files changed
+297 -18

No files matched your search

+73 -3
View File
@@ -2,6 +2,7 @@
基于 2026-08-19 的安全审计(源码 + haibara.ai 线上验证)整理。
2026-08-27 复审新增 #18#25(标题标注"复审新发现")。
2026-08-27 API 化(/api JSON 接口化,b1e9d9c..76b744d)后复审新增 #26#32(标题标注"API 化复审新发现")。
按优先级排序,完成后勾选并标注日期。
---
@@ -61,6 +62,17 @@
- [x] 表单 Action URL 由解析后的数值 ID 构造,不再回拼原始路由参数
- **验证**: ✅ `TestAdminUserRoutesRejectNonNumericIDs`5 组注入串 × GET/POST/DELETE 均被拒、数据零变更、合法 ID 不受影响);变异测试确认旧代码下注入用例失败("1 OR 1=1" 返回 200
### [x] 26. JSON API 无请求体大小限制——未认证内存耗尽 DoS(2026-08-27 API 化复审新发现)✅ 2026-08-27
- **位置**: `handlers/api.go`bindJSON / ShouldBindJSON)、`main.go`(全链路无 `http.MaxBytesReader`
- **问题**: 全部 POST/PUT/DELETE 已迁入 /api 且以 JSON 提交,但没有任何请求体大小上限:`net/http` 默认不限 bodyJSON 解码超长字符串 token 时按需整段分配内存。未认证端点 `/api/auth/login``/api/auth/register``/api/article/:slug/comments` 均可直接打击——CSRF 不构成障碍(先 GET 登录页/文章页即可取得合法 token 与会话 Cookie)。发送 GB 级 `"username":"AAAA…"` 可使单实例进程 OOM。
- **说明**: 旧表单端点(ParseForm)同样无限制,属沿袭缺陷而非 API 化引入;但 API 化是补齐的时机。
- **修复**:
- [x] 新增 `middleware/bodylimit.go` BodyLimit 中间件:非 multipart 请求统一 4 MiB(覆盖文章 Markdown 正文上限);multipart 按平台上传策略派生(启用的类型限制与全局默认取最大值,再 +1 MiB 编码开销)。Content-Length 已知且超限时读体前直接 413;其余经 `http.MaxBytesReader` 封装,读超限即截断
- [x] `handlers/api.go` bindJSON 识别 `*http.MaxBytesError` 返回 413 + `request_too_large`i18n 中英新增)
- [x] 中间件顺序调整为 sessions → 会话加固 → SetUserContext → **BodyLimit** → CSRF:必须在 CSRF 之前(CSRF 解析 multipart 会读取整个请求体);SetUserContext 提前使 413 文案可按请求语言翻译
- [x] 测试环境中间件链同步(security_test.go
- **验证**: ✅ `TestBodyLimitRejectsOversizedJSON`(已知长度 / chunked 流式两种形态的 4MB+ 登录请求 → 413/request_too_large)、`TestBodyLimitAllowsNormalJSON`(正常体放行至认证层 401)、`TestBodyLimitRejectsOversizedMultipart`3MB multipart 被拒且附件表零写入)、`TestBodyLimitGETBypass`GET 不受影响);`go build/vet/test -race ./...` 全绿
---
## P1 — 近期修复
@@ -113,6 +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 化复审新发现)
- **位置**: `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 不受影响;正常注册成功
### [ ] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现)
- **位置**: `handlers/comment.go`PostComment)、`models/seed.go`(默认策略)
- **问题**: 未认证即可提交评论(默认 `AllowGuest=true`),且无任何频率限制;配合默认 `GuestRequireApproval=false`(即时公开显示),开箱即用状态可被灌水机刷屏,同时放大 #26 的攻击面。
- **修复**:
- [ ] 按 IP(或 IP+文章)限流评论提交(建议 5 条/分钟/IP),超限 429 + i18n 新增 `comments_locked`
- [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true`
- **验证**: [ ] 测试:同 IP 高频提交 → 429;正常节奏不受影响
---
## P2 — 计划修复
@@ -166,6 +193,28 @@
- **修复**: 新增公共 `validateEmail`(空值放行,非空走 `net/mail.ParseAddress`,与评论处口径一致),四处统一调用
- **验证**: ✅ `TestRegisterRejectsInvalidEmail``TestProfileEmailValidation``TestAdminUserPasswordAndEmailEnforcement``abc` 均拒绝、合法邮箱正常)
### [ ] 29. 站点 favicon/logo 上传缺魔数校验(2026-08-27 API 化复审新发现)
- **位置**: `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
### [ ] 30. 最后管理员防线存在 TOCTOU 竞态(2026-08-27 API 化复审新发现)
- **位置**: `handlers/admin_user.go`UserUpdate 降级检查、UserDelete 删除检查)
- **问题**: `adminCount <= 1` 检查与后续 Save/Delete 非原子:两个并发的"降级/删除最后一位管理员"请求可同时通过检查,导致站点失去管理员。SQLite 单写锁下窗口极小;MySQL 部署是真实窗口(需管理员 CSRF 或双开标签配合,可利用性低)。
- **修复**:
- [ ] 检查+写入包进 `db.Transaction`,事务内先计数再更新(MySQL 下依赖行锁或 `SELECT ... FOR UPDATE`
- **验证**: [ ] 测试:并发降级最后管理员的请求,最终至少保留一个 admin(`-race`
### [ ] 31. 普通作者可置顶全站文章——需确认设计意图(2026-08-27 API 化复审新发现)
- **位置**: `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 路径不受影响
---
## P3 — 低优先级 / 观察项
@@ -198,6 +247,14 @@
- [x] 用户不存在时也执行一次 dummy bcrypt 比较(包级预生成哑哈希),抹平时间差;两分支均记录失败计数
- **验证**: ✅ 结构保证两分支均执行一次 bcrypt(`TestLoginTimingDoesNotRevealUser` 断言未知用户分支进入 Fail);大样本计时统计属人工运维验证,逻辑上两分支 B 树一致
### [ ] 32. 零碎加固(2026-08-27 API 化复审新发现)
- **位置**: 多处
- **问题与修复**:
- [ ] `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 → 拒绝
---
## 不需要修复(已确认安全,2026-08-27 复审复核仍成立)
@@ -208,12 +265,25 @@
- 附件路径穿越:SHA-256 内容寻址文件名
- 线上默认凭据:已修改(已验证)
- 注册接口:已关闭(已验证)
- 开放重定向:API 响应的 redirect 字段均为服务端常量(APIOK 只接收 handler 硬编码路径),用户输入不进入跳转目标
- JS 上下文注入:文章正文 `{{.Article.Content}}` 处于 `<script>` 字符串上下文,html/template 自动做 JS 转义;评论另经 data-md 属性 + DOMPurify 渲染
- API 越权(IDOR):/api/my/* 全部带 author_id 所有权约束;附件三端点均有 admin/上传者/文章作者校验;admin 与 my 的文章 CRUD 分组挂不同中间件
- API 化后旧表单端点已全部移除:不存在新旧端点权限口径不一致的并行暴露面
---
## 建议执行顺序
全部 25 项(含 P3-14/15/16/17、P3-25)均已修复并验证,仅 #18 方案 A(数据库移出存储根)为可选项
#1–#25 及 #26 已修复并验证。剩余待办 #27–#32,建议顺序
1. (可选)#18 方案 A:数据库文件移出存储根,需迁移存量 blog.db
2. 持续观察项:#15 Gravatar 开启时的反查风险(管理员知情)、#9 本地化 vendor 库版本升级提醒(随浏览器生态更新,重建 `scripts/build_tailwind.sh` 与 vendor 文件
1. ~~#26 请求体大小限制~~ ✅ 2026-08-27
2. **#27/#28 注册与评论限流**P1,可与 #26 的中间件基建衔接实施
3. **#29 favicon/logo 魔数校验**、**#30 最后管理员事务化**(P2,各自独立小改)
4. **#31 置顶权限**需先确认产品意图(作者可置顶是否预期)再决定修否
5. **#32 零碎项**随手修
历史遗留观察项(不阻塞):
- #18 方案 A:数据库文件移出存储根,需迁移存量 blog.db
- #15 Gravatar 开启时的反查风险(管理员知情)
- #9 本地化 vendor 库版本升级提醒(随浏览器生态更新,重建 `scripts/build_tailwind.sh` 与 vendor 文件)
+9 -2
View File
@@ -1,6 +1,7 @@
package handlers
import (
"errors"
"fmt"
"net/http"
@@ -59,10 +60,16 @@ func APIErrorf(c *gin.Context, status int, trKey string, args ...interface{}) {
}
// bindJSON 将 JSON 请求体绑定到 v。
// 绑定失败时返回 400 + api_invalid_request,并返回 false
// 使用前必须保证请求是 JSONContent-Type: application/json)。
// 绑定失败时返回 400 + api_invalid_request,并返回 false
// 请求体超出 BodyLimit 中间件设置的上限时返回 413 + request_too_large
// SECURITY_TODO #26)。使用前必须保证请求是 JSON。
func bindJSON(c *gin.Context, v interface{}) bool {
if err := c.ShouldBindJSON(v); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
APIError(c, http.StatusRequestEntityTooLarge, "request_too_large")
return false
}
APIError(c, http.StatusBadRequest, "api_invalid_request")
return false
}
+107
View File
@@ -0,0 +1,107 @@
package handlers
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go_blog/models"
)
// --- 请求体大小限制(SECURITY_TODO #26---
// TestBodyLimitRejectsOversizedJSON 验证未认证 JSON 端点的请求体超限防护:
// Content-Length 已知且超限时立即 413(不进入 CSRF 解析),未知长度
//chunked)时在读取处被 MaxBytesReader 截断,同样 413。
func TestBodyLimitRejectsOversizedJSON(t *testing.T) {
e := newSecurityTestEnv(t)
anonCookie, w := e.anonSession()
token := e.csrfTokenFrom(t, w)
// 4 MiB + 1 KiB 的超长 username 字段。
huge := strings.Repeat("A", 4<<20+1024)
// 情形 1:已知 Content-Lengthbytes.Reader 可 Seek)→ 进入处理器前
// 即被 413 拒绝。
w = postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, token,
map[string]string{"username": huge, "password": "x"})
if w.Code != http.StatusRequestEntityTooLarge || respCode(w) != "request_too_large" {
t.Fatalf("known length: status=%d code=%q body=%s, want 413/request_too_large",
w.Code, respCode(w), w.Body.String())
}
// 情形 2:无 Content-Length(流式 body)→ MaxBytesReader 在读取中
// 截断,bindJSON 识别后返回 413。
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
io.LimitReader(bytes.NewReader([]byte(`{"username":"`+huge+`"}`)), int64(len(huge))+64))
req.ContentLength = -1
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", token)
req.Header.Set("Cookie", anonCookie)
w = httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge || respCode(w) != "request_too_large" {
t.Fatalf("unknown length: status=%d code=%q body=%s, want 413/request_too_large",
w.Code, respCode(w), w.Body.String())
}
}
// TestBodyLimitAllowsNormalJSON 验证合法大小的请求不受影响:
// 未认证 POST 走到登录校验(401 login_error),而非 413。
func TestBodyLimitAllowsNormalJSON(t *testing.T) {
e := newSecurityTestEnv(t)
anonCookie, w := e.anonSession()
token := e.csrfTokenFrom(t, w)
w = postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, token,
map[string]string{"username": "alice", "password": "wrong"})
if w.Code != http.StatusUnauthorized {
t.Fatalf("normal body: status=%d, want 401 (passed through, rejected by auth)", w.Code)
}
}
// TestBodyLimitRejectsOversizedMultipart 验证 multipart 上传路径:
// 超过平台策略(默认 1 MiB + 1 MiB 开销)的请求被 CSRF/处理器解析前
// 的上限截断,不落盘、不创建附件行。
func TestBodyLimitRejectsOversizedMultipart(t *testing.T) {
e := newSecurityTestEnv(t)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
// 构造 ~3 MiB 的 multipart 文件(远超 1 MiB 默认 + 1 MiB 开销)。
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("session_token", "tok")
fw, _ := mw.CreateFormFile("file", "big.txt")
_, _ = fw.Write(bytes.Repeat([]byte("x"), 3<<20))
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/my/articles/attachments", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("X-CSRF-Token", token)
req.Header.Set("Cookie", alice)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge && w.Code != http.StatusBadRequest {
t.Fatalf("oversized multipart: status=%d, want 413/400 (rejected before disk write)", w.Code)
}
var count int64
e.db.Model(&models.Attachment{}).Where("filename = ?", "big.txt").Count(&count)
if count != 0 {
t.Fatalf("oversized multipart created %d attachment rows, want 0", count)
}
}
// TestBodyLimitGETBypass 验证安全方法不受限制(GET 无请求体)。
func TestBodyLimitGETBypass(t *testing.T) {
e := newSecurityTestEnv(t)
w := e.do(http.MethodGet, "/login", "", nil, "")
if w.Code != http.StatusOK {
t.Fatalf("GET /login: status=%d, want 200", w.Code)
}
}
+2 -1
View File
@@ -79,8 +79,9 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
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.Use(middleware.BodyLimit())
r.Use(middleware.CSRFProtect())
r.GET("/login", LoginPage())
r.GET("/register", RegisterPage(db))
+2
View File
@@ -450,6 +450,7 @@ var translations = map[Lang]map[string]string{
"api_unauthorized": "Please sign in first.",
"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.",
"user_not_found": "User not found.",
},
ZH: {
@@ -886,6 +887,7 @@ var translations = map[Lang]map[string]string{
"api_unauthorized": "请先登录。",
"api_forbidden": "您没有权限执行此操作。",
"api_invalid_request": "请求参数格式不正确。",
"request_too_large": "请求体超过大小限制。",
"user_not_found": "用户不存在。",
},
}
+12 -5
View File
@@ -105,13 +105,20 @@ func main() {
sessions.Default(c).Options(opts)
})
// 6b. CSRF 防护(必须在会话中间件之后运行)。
router.Use(middleware.CSRFProtect())
// 7. 全局上下文中间件(为模板设置 IsLoggedIn、Username 等)。
// 6b. 全局上下文中间件(为模板设置 IsLoggedIn、Username 等)。
// 先于 CSRF 注册:BodyLimit 的 413 文案按请求语言翻译,
// 需要这时 tr 已注入上下文。
router.Use(middleware.SetUserContext(db))
// 8. 注册路由。
// 6c. 请求体大小限制(SECURITY_TODO #26):必须在 CSRF 之前注册——
// CSRF 解析 multipart 表单会读取整个请求体,不设上限时未认证请求
// 即可通过 multipart 解析耗尽内存/磁盘。
router.Use(middleware.BodyLimit())
// 6d. CSRF 防护(必须在会话中间件之后运行)。
router.Use(middleware.CSRFProtect())
// 7. 注册路由。
registerRoutes(router, cfg, db, loginLimiter)
// 9. 启动服务器。
+7 -7
View File
@@ -19,9 +19,9 @@ func isAPIRequest(c *gin.Context) bool {
return strings.HasPrefix(c.Request.URL.Path, "/api")
}
// apiAuthError 以 API 错误格式终止请求(401 未认证 / 403 无权限)。
// 文案按请求语言翻译(SetUserContext 已在全局中间件中注入 tr)。
func apiAuthError(c *gin.Context, status int, trKey string) {
// apiError 以 API 错误格式终止请求(401 未认证 / 403 无权限 / 413 请求体
// 过大等)。文案按请求语言翻译(SetUserContext 已在全局中间件中注入 tr)。
func apiError(c *gin.Context, status int, trKey string) {
tr, _ := c.Get("tr")
m, _ := tr.(map[string]string)
code := trKey
@@ -83,7 +83,7 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
uid, ok := sessionUserID(session)
if !ok {
if isAPIRequest(c) {
apiAuthError(c, http.StatusUnauthorized, "api_unauthorized")
apiError(c, http.StatusUnauthorized, "api_unauthorized")
return
}
c.Redirect(http.StatusFound, "/login")
@@ -95,7 +95,7 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
// 账户已不可用——销毁会话,防止过期 Cookie 被重放。
clearUserSession(session)
if isAPIRequest(c) {
apiAuthError(c, http.StatusUnauthorized, "api_unauthorized")
apiError(c, http.StatusUnauthorized, "api_unauthorized")
return
}
c.Redirect(http.StatusFound, "/login")
@@ -115,7 +115,7 @@ func AdminRequired(db *gorm.DB) gin.HandlerFunc {
uid, ok := sessionUserID(session)
if !ok {
if isAPIRequest(c) {
apiAuthError(c, http.StatusUnauthorized, "api_unauthorized")
apiError(c, http.StatusUnauthorized, "api_unauthorized")
return
}
c.Redirect(http.StatusFound, "/login")
@@ -125,7 +125,7 @@ func AdminRequired(db *gorm.DB) gin.HandlerFunc {
var user models.User
if err := db.First(&user, uid).Error; err != nil || user.Role != models.RoleAdmin {
if isAPIRequest(c) {
apiAuthError(c, http.StatusForbidden, "api_forbidden")
apiError(c, http.StatusForbidden, "api_forbidden")
return
}
c.Redirect(http.StatusFound, "/admin")
+85
View File
@@ -0,0 +1,85 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"go_blog/models"
)
// jsonBodyLimit 是非 multipart 请求体(JSON / 表单 / 未知类型)的统一上限
// SECURITY_TODO #26)。4 MiB 覆盖最大的合法载荷——文章 Markdown 正文;
// 登录/注册/评论等小载荷共用同一上限,避免按端点维护多套配置。
const jsonBodyLimit int64 = 4 << 20
// multipartOverhead 叠加在上传策略派生的大小之上,容纳 multipart 编码
// 开销(边界、_csrf/session_token 等表单字段)。
const multipartOverhead int64 = 1 << 20
// BodyLimit 限制不安全方法(POST/PUT/PATCH/DELETE)的请求体大小,
// 防止未认证的内存/磁盘耗尽 DoSSECURITY_TODO #26):
//
// - Content-Length 已知且超限时立即返回 413,不读取请求体;
// - 其余请求体经 http.MaxBytesReader 封装:超限后读取立即失败,
// JSON 路径由 handlers.bindJSON 识别并转为 413multipart 路径的
// 解析在 CSRF/处理器中进行,同样在限额处截断。
//
// 必须注册在 CSRFProtect 之前——CSRF 中间件解析 multipart 表单
// (查找 _csrf 字段)会读取整个请求体;必须在 SetUserContext 之后,
// 使 413 文案可按请求语言翻译(apiError 读取上下文中的 tr)。
func BodyLimit() gin.HandlerFunc {
return func(c *gin.Context) {
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
c.Next()
return
}
if c.Request.Body == nil {
c.Next()
return
}
limit := bodyLimitFor(c.Request.Header.Get("Content-Type"))
if c.Request.ContentLength > limit {
apiError(c, http.StatusRequestEntityTooLarge, "request_too_large")
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
c.Next()
}
}
// bodyLimitFor 依据 Content-Type 返回请求体上限:
// multipart 按平台上传策略派生,其余(JSON / urlencoded / 未知)统一
// 走 JSON 上限。urlencoded 本身已被 net/http 限制在 1 MiB 内
// parsePostForm),这里再统一封顶一层。
func bodyLimitFor(contentType string) int64 {
ct := strings.ToLower(strings.TrimSpace(contentType))
if strings.HasPrefix(ct, "multipart/form-data") {
return maxUploadBodyLimit() + multipartOverhead
}
return jsonBodyLimit
}
// maxUploadBodyLimit 返回当前平台策略下可被接受的最大单文件大小:
// 全局默认值与各启用类型的按类型限制取最大值,且不低于编译期默认,
// 防止异常的零值配置把上限压垮到小于合法上传。
func maxUploadBodyLimit() int64 {
cfg := models.GetUploadConfig()
max := cfg.DefaultMaxSize
if max < models.DefaultUploadMaxSize {
max = models.DefaultUploadMaxSize
}
types := models.GetUploadFileTypes()
for i := range types {
if !types[i].Enabled {
continue
}
if s := types[i].EffectiveMaxSize(cfg.DefaultMaxSize); s > max {
max = s
}
}
return max
}