Files
go_blog/SECURITY_TODO.md
T
kevin e314b05670 fix: 修复 P1 复审漏洞 #20 #21(禁用用户会话失效 + 头像 XSS 链)
- #20 AuthRequired 改为 AuthRequired(db):受保护路由每次回库校验
  Status == StatusNormal 且未软删,失败清 session(保留 lang/csrf_token,
  与登录轮换口径一致)并 302 /login;session user_id 先断言为数值再入
  GORM(呼应 #19),AdminRequired 同步加固;SetUserContext 仅在用户
  存在且状态正常时置 is_logged_in——禁用用户发评论不再自动通过,
  回落游客审核策略
- #21 头像两个分支(UploadAvatar / UpdateProfile)强制
  Category == image,解码失败直接拒绝、删除"回退存原始字节"路径,
  统一经 processAvatar 解码→256px 缩放→JPEG 重编码;addUploadFileType
  增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs),
  拒绝添加并在上传设置页提示(模板 + 中英 i18n)
- 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠
  回退存原始字节掩盖,PNG 头像从未真正处理过),补 blank import
- 新增回归测试 session_upload_security_test.go(6 用例:禁用/锁定/
  软删旧 cookie 302、锁定用户评论转 pending、6 组危险扩展拒绝、
  伪装扩展名头像拒绝且磁盘零写入、正常图片转存 .jpg;已变异验证:
  去掉任一修复对应测试即失败)
- SECURITY_TODO.md 勾选 #20/#21 并更新执行顺序
2026-08-27 17:35:46 +08:00

222 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 安全修复 TODO
基于 2026-08-19 的安全审计(源码 + haibara.ai 线上验证)整理。
2026-08-27 复审新增 #18#25(标题标注"复审新发现")。
按优先级排序,完成后勾选并标注日期。
---
## P0 — 立即修复
### [x] 1. 会话密钥弱回退(可伪造管理员会话)✅ 2026-08-19
- **位置**: `config/config.go``generateSecret` / `applyDefaults` 回退)
- **问题**: secret 缺失时回退为 SHA-256(主机名+PID),两者均可被外部推测/爆破,攻击者可离线伪造任意用户会话 cookie。
- **修复**:
- [x] `generateSecret()` 改用 `crypto/rand` 生成 32 字节随机数
- [x] 已有配置加载路径中 secret 为空时:拒绝启动(`log.Fatalf`),不再静默回退;配置文件读取失败也改为直接退出
- [x] 首次生成配置文件时写入强随机 secret(保持 `install_linux.sh` 的 openssl 路径不变)
- **验证**: ✅ 新密钥为 crypto/rand 输出;缺失 secret 时启动直接报错
### [x] 2. 全站无 CSRF 防护 ✅ 2026-08-19
- **位置**: 全部 POST 路由(登录/注册/文章/评论/管理后台/设置/附件)
- **问题**: 仅靠 cookie 认证,无 CSRF token;线上 cookie 无 SameSite 属性,浏览器默认 Lax 保护不完整(Chrome Lax+POST 豁免、Safari 差异)。
- **修复**:
- [x] 新增 `middleware/csrf.go`:同步器令牌模式(session 存储、常量时间比较),表单 `_csrf` 字段或 `X-CSRF-Token` 头二选一,不匹配返回 403
- [x] 覆盖全部 30 个 POST 表单(含游客评论表单);AJAX(附件上传/删除、头像上传)经 `<meta name="csrf-token">` 下发 token 并以请求头携带
- [x] `/article/:slug/comments` 游客 POST 一并覆盖(游客同样有 session)
- **验证**: ✅ `middleware/csrf_test.go` 7 用例 + 端到端 curl 冒烟(无 token/伪造 token 403,有效 token 302
### [x] 3. 附件接口越权(IDOR)✅ 2026-08-19
- **位置**: `handlers/attachment.go`DeleteAttachment / ListAttachments / UploadAttachment 的 article_id
- **问题**: `/my/articles/attachments/*` 仅要求登录,无所有权校验;任意登录用户可删除/列出全站任意附件、向他人文章挂附件。
- **修复**:
- [x] `DeleteAttachment`:admin / 上传者 / 所属文章作者三者之一,否则 403
- [x] `ListAttachments`:文章作者或 admin,否则 403
- [x] `UploadAttachment``article_id != 0` 时校验文章归属(admin 除外),否则 403
- [x] 单元测试(`handlers/security_test.go`:越权 403 / 本人 200 / admin 覆盖)
- **验证**: ✅ 普通用户 A 删除用户 B 的附件 -> 403(测试覆盖)
### [x] 4. 会话固定(Session Fixation)✅ 2026-08-19
- **位置**: `handlers/auth.go`Login / Register 自动登录)
- **问题**: 登录成功后未清空旧 session,直接写入 user_id,固定攻击可劫持登录后会话。
- **修复**:
- [x] 认证成功后先 `session.Clear()` 再写入 `user_id`/`username` 并 Save;保留 lang 与 csrf_token(避免多标签页已渲染表单失效)
- **验证**: ✅ 登录前后 cookie 值不同,旧 cookie 无法访问受保护路由(`TestLoginRotatesSession`
### [x] 18. SQLite 数据库文件可被公开下载(全库泄露)(2026-08-27 复审新发现)✅ 2026-08-27
- **位置**: `main.go:72``router.Static("/uploads", cfg.Path)` 把存储根整体挂载为静态目录)、`models/db.go:36``blog.db` 就放在该目录下)
- **问题**: 未认证即可 `GET /uploads/blog.db` 下载整库——含 bcrypt 密码哈希、用户邮箱、评论者 IP/邮箱、私密评论(IsPrivate)、浏览记录等。
- **修复**(采用方案 B,白名单挂载):
- [x] `main.go` 新增 `registerUploadRoutes`:只挂载 `attachments/``avatars/``logos/` 及当前配置的附件存储目录,存储根不再整体暴露
- [x] 自定义 `serveUploadDir` handler:禁用目录列表(目录/`..`/`\` 一律 404),只服务具体文件
- [x] `safeStorageDir`storage_dir 含穿越/绝对路径/反斜杠时回退 `attachments`(为 #22 提供纵深防御)
- [ ] (可选加固)方案 A:将数据库文件移出存储根,需迁移存量 blog.db,暂缓
- **验证**: ✅ `main_test.go` 4 用例(blog.db 404 / 无目录列表 / 穿越失败 / 三目录正常服务、自定义 storage_dir、不安全回退、重名去重)
### [x] 19. GORM 字符串条件 SQL 注入(admin 用户管理 3 处)(2026-08-27 复审新发现)✅ 2026-08-27
- **位置**: `handlers/admin_user.go:253、289、362``db.First(&user, id)`id 为 `c.Param("id")` 字符串)
- **问题**: GORM 对 `First(dest, s)`:s 为非数字字符串且无附加参数时按原始 SQL 条件拼入 WHEREv1.31.1 `statement.go:293-303` 已核实)。`GET /admin/users/1 OR 1=1/edit` 即可注入;UserEditPage 为 GET 无 CSRF 拦截,可诱导已登录管理员点击链接触发盲注。
- **修复**:
- [x] UserEditPage / UserUpdate / UserDelete 三处:先 `uintFormID` 解析为数值,非数值(0)直接 302 回列表页;查询一律走主键参数化
- [x] 表单 Action URL 由解析后的数值 ID 构造,不再回拼原始路由参数
- **验证**: ✅ `TestAdminUserRoutesRejectNonNumericIDs`5 组注入串 × GET/POST/DELETE 均被拒、数据零变更、合法 ID 不受影响);变异测试确认旧代码下注入用例失败("1 OR 1=1" 返回 200
---
## P1 — 近期修复
### [x] 5. Cookie 缺 Secure / SameSite 标志 ✅ 2026-08-19
- **位置**: `main.go`session store)、`handlers/comment.go:82`comment_uid
- **修复**:
- [x] store 默认 `SameSite: Lax``Secure` 按请求动态设置(`middleware/https.go` 检测 TLS 或 X-Forwarded-Proto),通过中间件在每次请求时应用到 session cookie
- [x] `comment_uid` 游客 cookie 同步补齐 `SameSite=Lax` + HTTPS 下 `Secure`
- **验证**: ✅ 模拟 HTTPS 请求响应头 `Set-Cookie: ... HttpOnly; Secure; SameSite=Lax`;冒烟测试通过
### [x] 6. 缺失安全响应头 ✅ 2026-08-19
- **位置**: 新增 `middleware/security_headers.go`(全局第一个注册)
- **修复**:
- [x] `Content-Security-Policy`default-src 'self' + 现有 CDN 白名单 + frame-ancestors 'none' 等)
- [x] `X-Content-Type-Options: nosniff``X-Frame-Options: DENY``Referrer-Policy``Permissions-Policy`
- [x] `Strict-Transport-Security`(仅 HTTPS 请求下发,未加 includeSubDomains 以免影响 HTTP 子域)
- **说明**: CSP 含 `'unsafe-inline'`(模板内联 script/style 必需);待 P2-9 CDN 本地化后可进一步收紧
- **验证**: ✅ `middleware/security_headers_test.go`headers 存在性、HSTS 条件下发)+ 冒烟 curl 确认
### [x] 7. X-Forwarded-For 伪造(IP 审计/浏览量可刷)✅ 2026-08-19
- **位置**: `handlers/helpers.go`GetClientIP)、`config/config.go`WebConfig.TrustedProxies)、`main.go`
- **修复**:
- [x] 删除手动解析 XFF 首值逻辑,`GetClientIP` 改为 `c.ClientIP()`
- [x] `router.SetTrustedProxies(cfg.Web.TrustedProxies)`;新增 `web.trusted_proxies` 配置项(默认 `["127.0.0.1", "::1"]`unix socket 部署自动信任)
- [x] gin 内部 XFF 从右往左取第一个不可信 IP:直接客户端伪造的 XFF 被忽略
- **验证**: ✅ `middleware/clientip_test.go`(直接连接带假 XFF 取真实 IP / 代理链取最右不可信条目)
### [x] 8. goroutine 数据竞争(use-after-return)✅ 2026-08-19
- **位置**: `handlers/home.go`ArticleDetail → recordArticleView
- **修复**:
- [x] goroutine 启动前同步提取 userID / ip / UA 为局部变量,`recordArticleView` 不再触碰 gin.Context 与 session
- **验证**: ✅ `go test -race ./...` 全绿
### [x] 20. 被禁用/锁定/删除用户的会话不失效(2026-08-27 复审新发现)✅ 2026-08-27
- **位置**: `middleware/auth.go:16-27`AuthRequired 只看 session 是否有 user_id,不回库校验)
- **问题**: 登录时的状态检查(`handlers/auth.go:50`)只在登录瞬间生效。管理员禁用/锁定/软删用户后,其已持有的 cookie 在最长 24h 内仍完全可用:发评论自动 Approved、写文章、传附件;`SetUserContext` 对已软删用户仍置 `is_logged_in=true`
- **修复**:
- [x] `AuthRequired(db)` 回库校验 `Status == StatusNormal` 且未软删,失败则清 session(保留 lang 与 csrf_token)并跳转 /loginsession user_id 一律先断言为数值再入 GORM(呼应 #19
- [x] SetUserContext:用户查询失败或非正常状态时 `is_logged_in` 置 false(评论自动通过随之失效,回落游客审核策略)
- **验证**: ✅ `TestDisabledUserSessionInvalidated`disabled/locked 旧 cookie → 302 /login)、`TestSoftDeletedUserSessionInvalidated``TestDisabledUserCommentsRequireApproval`(锁定后评论转 pending
### [x] 21. 头像上传缺类别校验 + 可添加任意扩展名 → 存储型 XSS 链(2026-08-27 复审新发现)✅ 2026-08-27
- **位置**: `handlers/profile.go:196-208`UploadAvatar 未限制 image 类别)、`handlers/profile.go:223-226`processAvatar 失败回退存原始字节)、`handlers/profile.go:96-107`UpdateProfile 头像分支同样无类别校验、原样落盘)、`handlers/settings.go:269-290`addUploadFileType 无危险扩展黑名单)
- **问题**: logo/favicon 上传要求 `Category == image`settings.go:113/153),但头像上传只查扩展名白名单且解码失败仍存原始文件;管理员又可在上传设置里添加任意扩展名(含 `.html`/`.svg`)。组合链:添加 `.html` 类型 → 任意登录用户以头像名义上传 HTML → 落在 `/uploads/avatars/` 同源可执行(CSP `script-src 'self' 'unsafe-inline'` 放行)。
- **修复**:
- [x] UploadAvatar / UpdateProfile 头像分支强制 `check.Type.Category == models.CategoryImage`,解码失败直接拒绝(不再回退存原始字节)
- [x] UpdateProfile 头像同样经 processAvatar 解码→缩放→JPEG 重编码,原始字节不再落盘
- [x] addUploadFileType 增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs),拒绝添加并提示(settings_upload 页新增错误提示 + i18n
- [x] 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠"失败回退"掩盖),补 blank import
- **验证**: ✅ `TestAddUploadFileTypeRejectsDangerousExtensions`(6 组危险扩展拒绝 + .md 正常)、`TestUploadAvatarRejectsNonImage`(.html 拒绝 / 图片扩展名包 HTML 拒绝 / 正常 PNG 转存 .jpg)、`TestUpdateProfileAvatarRejectsNonImage`(表单头像同样拒绝 + 正常图片成功)
---
## P2 — 计划修复
### [ ] 9. 第三方 CDN 无 SRI / Tailwind dev CDN
- **位置**: `templates/layouts/base.html:16-20``:118-122`
- **修复**:
- [ ] 将 marked / DOMPurify / highlight.js / cropperjs / easymde 下载到 `static/vendor/`,走 go:embed 本地分发(静态管线已具备)
- [ ] 替换 `cdn.tailwindcss.com` 为构建期生成的静态 CSS(或至少加 SRI)
- [ ] 本地化后配合 #6 收紧 CSP 为 `default-src 'self'`
- **验证**: 断网第三方域名后页面渲染功能完整;CSP 无违规报告
### [ ] 10. 登录无速率限制
- **位置**: `handlers/auth.go:33`
- **修复**:
- [ ] 按 IP + 用户名维度做失败计数(内存或 DB),如 5 次失败锁定 15 分钟
- [ ] 失败提示保持统一(现有 `?error=1` 已做用户名枚举防护,保持)
- **验证**: 连续错误登录后返回锁定提示
### [ ] 11. 配置文件权限过宽
- **位置**: `config/config.go:115`
- **修复**: `os.WriteFile(configFile, data, 0640)`secret 写入后可选 `os.Chmod`
### [ ] 12. 首启弱凭据 admin/admin
- **位置**: `models/db.go:58-81`
- **修复**:
- [ ] 方案 A:首启生成随机密码打印一次性提示
- [ ] 方案 B:admin 账户标记"必须改密",登录后强制跳转改密页
- **说明**: 线上已改密(已验证),此项为防御新部署
### [ ] 13. Unix socket 权限 666
- **位置**: `install_linux.sh:80`
- **修复**: `chmod 660` + `chown root:blog_go`(反向代理进程加入同组),避免本机任意用户绕过 Cloudflare 直连
### [ ] 22. storage_dir 路径穿越(2026-08-27 复审新发现)
- **位置**: `handlers/settings.go:262-264`(任意 storage_dir 直接入库)、`handlers/attachment.go:21-27``filepath.Join` 不清洗 `../`
- **问题**: 管理员把 storage_dir 设为 `../../tmp` 类值后,附件上传/删除将发生在存储根之外(越界读写)。
- **修复**:
- [ ] 校验 storage_dir 为单个安全路径段(如 `^[A-Za-z0-9_-]+$`),否则拒绝保存
- **验证**: 提交 `../evil` → 拒绝;`attachments` → 正常
### [ ] 23. 密码策略缺失(改密/管理员建号无最小长度)(2026-08-27 复审新发现)
- **位置**: `handlers/profile.go:146-159`UpdateProfile 改密)、`handlers/admin_user.go` UserCreate/UserUpdate(建号/重置密码)
- **问题**: 注册要求密码 ≥6 位,但个人改密与管理员建号/重置密码均可设 1 位弱密码。
- **修复**:
- [ ] 抽公共 `validatePassword`,三处统一调用(最小长度与注册口径一致)
- **验证**: 改密为 1 位 → 拒绝
### [ ] 24. 邮箱字段不校验格式(2026-08-27 复审新发现)
- **位置**: `handlers/auth.go:130`(注册)、`handlers/profile.go:81-83`(改邮箱)、`handlers/admin_user.go`(建号/编辑)
- **问题**: 仅评论处调用 `mail.ParseAddress`;注册/改资料/管理员建号均可写入非法邮箱(脏数据 + Gravatar 哈希异常)。
- **修复**:
- [ ] 抽公共 `validateEmail`,各处统一调用
- **验证**: 注册/改邮箱提交 `abc` → 拒绝
---
## P3 — 低优先级 / 观察项
### [ ] 14. 上传不校验文件真实类型
- **位置**: `handlers/upload_validator.go:33-58`
- **修复**: 用 `github.com/gabriel-vasile/mimetype`(已在依赖树中)校验 magic bytes 与扩展名/MIME 一致;不一致则拒绝
- **说明**: 白名单无 .svg/.html,存储型 XSS 风险低,主要是恶意文件托管风险
### [ ] 15. Gravatar MD5 邮箱哈希可反查
- **位置**: `handlers/comment.go:88-91`
- **说明**: Gravatar 协议本身如此;若在意隐私可加后台开关(已有 UseGravatar 开关可关闭)
### [ ] 16. RSS 以 Host 头构造 baseURL
- **位置**: `handlers/rss.go:60-64`
- **修复**: 从站点设置中读取固定站点 URL,仅在与请求 Host 不符时告警
- **说明**: Cloudflare 会校验 Host,实际可利用性低
### [ ] 17. bcrypt cost 偏低
- **位置**: `models/user.go:41`DefaultCost=10
- **修复**: 提升到 12;已有哈希在用户下次改密时自然升级
### [ ] 25. 登录计时侧信道(用户名枚举)(2026-08-27 复审新发现)
- **位置**: `handlers/auth.go:39-47`
- **问题**: 用户不存在时立即返回、不执行 bcrypt;密码错误时执行 bcrypt(~100ms)。响应时间差可用于枚举有效用户名,与未修复的 #10(无速率限制)叠加放大。
- **修复**:
- [ ] 用户不存在时也执行一次 dummy bcrypt 比较(对固定哑哈希),抹平时间差
- [ ]#10 的速率限制一并实施
- **验证**: 大样本计时统计:两分支无显著差异
---
## 不需要修复(已确认安全,2026-08-27 复审复核仍成立)
- SQL 注入:全参数化查询(GORM)——唯一例外见 #19admin_user.go 3 处字符串条件)
- XSShtml/template 自动转义 + 评论双防御(服务端 strip + DOMPurify
- 密码哈希:bcrypt
- 附件路径穿越:SHA-256 内容寻址文件名
- 线上默认凭据:已修改(已验证)
- 注册接口:已关闭(已验证)
---
## 建议执行顺序
P0/P1 原有 8 项及 P0/P1 新发现 #18#21 均已完成。剩余:
1. **#22#23#24**(校验类小改动,可合并一个 PR)
2. **#9#10#25**(CDN 本地化、登录限速 + 计时抹平,同一主题)
3. 其余 P2/P3#11 配置权限、#12 首启弱凭据、#13 socket 权限、#14 magic bytes、#17 bcrypt cost)按迭代排入
4. (可选)#18 方案 A:数据库文件移出存储根