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:
8 files changed
+297
-18
No files matched your search
+73
-3
@@ -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` 默认不限 body,JSON 解码超长字符串 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`
|
||||
- **问题**: 登录有限流器(#10,IP+用户名),注册完全没有。`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 → 落库为 false;admin 路径不受影响
|
||||
|
||||
---
|
||||
|
||||
## 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
@@ -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。
|
||||
// 使用前必须保证请求是 JSON(Content-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
|
||||
}
|
||||
|
||||
@@ -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-Length(bytes.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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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": "用户不存在。",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)的请求体大小,
|
||||
// 防止未认证的内存/磁盘耗尽 DoS(SECURITY_TODO #26):
|
||||
//
|
||||
// - Content-Length 已知且超限时立即返回 413,不读取请求体;
|
||||
// - 其余请求体经 http.MaxBytesReader 封装:超限后读取立即失败,
|
||||
// JSON 路径由 handlers.bindJSON 识别并转为 413;multipart 路径的
|
||||
// 解析在 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
|
||||
}
|
||||
Reference in New Issue
Block a user