220 lines
19 KiB
Markdown
220 lines
19 KiB
Markdown
# 安全修复 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 条件拼入 WHERE(v1.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)并跳转 /login;session 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 — 计划修复
|
||
|
||
### [x] 9. 第三方 CDN 无 SRI / Tailwind dev CDN ✅ 2026-08-27
|
||
- **位置**: `templates/layouts/base.html:16-20`、`:118-122`、`middleware/security_headers.go`
|
||
- **修复**:
|
||
- [x] marked / DOMPurify / highlight.js / cropperjs / easymde 固定版本下载至 `static/vendor/`,经 go:embed 本地分发(easymde 自含 CodeMirror;拼写检查字典为可选外链,断网静默降级)
|
||
- [x] `cdn.tailwindcss.com` 替换为构建期静态 CSS:`scripts/build_tailwind.sh`(tailwindcss 3.4.17,content 扫 templates+handlers+main.go 保证 Go 侧拼接类不漏),产物 `static/css/app.css` 提交仓库
|
||
- [x] 配合 #6 收紧 CSP 为 `script-src 'self' 'unsafe-inline'`,移除全部 CDN 域名
|
||
- **验证**: ✅ 断网第三方域名后页面渲染功能完整(puppeteer 冒烟:首页/编辑器资源 200、Tailwind 样式生效、无 JS 报错);CSP 已无第三方来源
|
||
|
||
### [x] 10. 登录无速率限制 ✅ 2026-08-27
|
||
- **位置**: `handlers/auth.go:33`
|
||
- **修复**:
|
||
- [x] 新建 `handlers/login_ratelimit.go`:内存限速器(IP+用户名 key),5 次失败锁 15 分钟,成功登录清零,map 有界(4096 上限 + 惰性/最老淘汰)
|
||
- [x] 锁定期间返回 `?error=locked` 明确提示(不泄露用户存在性);失败提示保持统一 `?error=1`
|
||
- **验证**: ✅ `TestLoginRateLimited`(5 次失败→锁定→正确密码也被拒→Reset 恢复→其他用户不受影响)
|
||
|
||
### [x] 11. 配置文件权限过宽 ✅ 2026-08-27
|
||
- **位置**: `config/config.go`
|
||
- **修复**: `os.WriteFile(configFile, data, 0644)` → `0640`(secret 写入后不再组/世界可读;`install_linux.sh` 原有 0640 保持一致)
|
||
- **验证**: ✅ `TestConfigFileCreatedNotWorldReadable`(创建后 perm == 0640)
|
||
|
||
### [x] 12. 首启弱凭据 admin/admin ✅ 2026-08-27(方案 A)
|
||
- **位置**: `models/db.go`
|
||
- **修复**(方案 A):
|
||
- [x] 首启生成 16 位随机密码(crypto/rand,字母表排除易混淆字符),一次性打印日志;不再使用 admin/admin
|
||
- **说明**: 线上已改密(已验证),此项为防御新部署
|
||
- **验证**: ✅ `TestRandomAdminPassword`(长度/字符合法/两次生成不同)
|
||
|
||
### [x] 13. Unix socket 权限 666 ✅ 2026-08-27
|
||
- **位置**: `install_linux.sh:80`
|
||
- **修复**: `chmod 666` → `chown blog_go:blog_go + chmod 660`,安装结束打印提示:反向代理运行用户需 `usermod -aG blog_go <proxy_user>`
|
||
- **说明**: 部署脚本改动,需在 Linux 环境验证(本机无法执行);本机任意用户已不能再绕过 Cloudflare 直连
|
||
|
||
### [x] 22. storage_dir 路径穿越 ✅ 2026-08-27
|
||
- **位置**: `handlers/settings.go`(saveUploadConfig)、`handlers/attachment.go`、`main.go` safeStorageDir
|
||
- **修复**:
|
||
- [x] saveUploadConfig 校验存储目录为单个安全路径段(`^[A-Za-z0-9_-]+$`,手写 safeStorageDirName),非法直接拒绝并提示 `?error=illegal_dir`(i18n 新增)
|
||
- [x] 保留 main.go `safeStorageDir` 运行时兜底作为纵深防御(不改)
|
||
- **验证**: ✅ `TestStorageDirTraversalRejected`(6 组穿越值均拒绝且库中值不变 / 合法值正常保存)+ `TestSafeStorageDirNameAndValidators` 纯函数表驱动
|
||
|
||
### [x] 23. 密码策略缺失(改密/管理员建号无最小长度)✅ 2026-08-27
|
||
- **位置**: `handlers/profile.go`(改密)、`handlers/admin_user.go` UserCreate/UserUpdate
|
||
- **修复**: 新增公共 `validatePassword`(≥6 位,与注册口径一致),三处统一调用,失败回渲染表单/跳转 + i18n 提示(profile_password_short / user_password_short)
|
||
- **验证**: ✅ `TestProfilePasswordMinLength`(1 位拒绝且旧哈希保留 / 6 位成功)、`TestAdminUserPasswordAndEmailEnforcement`(建号/重置短密码均拒绝)
|
||
|
||
### [x] 24. 邮箱字段不校验格式 ✅ 2026-08-27
|
||
- **位置**: `handlers/auth.go`(注册)、`handlers/profile.go`(改邮箱)、`handlers/admin_user.go`(建号/编辑)
|
||
- **修复**: 新增公共 `validateEmail`(空值放行,非空走 `net/mail.ParseAddress`,与评论处口径一致),四处统一调用
|
||
- **验证**: ✅ `TestRegisterRejectsInvalidEmail`、`TestProfileEmailValidation`、`TestAdminUserPasswordAndEmailEnforcement`(`abc` 均拒绝、合法邮箱正常)
|
||
|
||
---
|
||
|
||
## P3 — 低优先级 / 观察项
|
||
|
||
### [x] 14. 上传不校验文件真实类型 ✅ 2026-08-27
|
||
- **位置**: `handlers/upload_validator.go`(新增 `contentMatchesType`)、`handlers/attachment.go`、`handlers/profile.go`
|
||
- **修复**: `mimetype`(v1.4.12 转直接依赖)magic-bytes 检测与扩展名配置的 MimeType 比对(宽容策略:策略为空/`application/octet-stream`/内容不可检测时放行,扩展名白名单仍为主闸);附件 AJAX 400 + 头像 400/表单错误
|
||
- **验证**: ✅ `TestUploadAttachmentRejectsMismatchedContent`(.txt 内容为 PNG 字节 → 400,真文本 → 200)、`TestContentMatchesTypeTable`(8 用例表驱动)
|
||
|
||
### [x] 15. Gravatar MD5 邮箱哈希可反查 ✅ 2026-08-27
|
||
- **位置**: `models/seed.go`、`models/comment_config.go`(defaultCommentConfig)、`handlers/admin_comment.go`、`templates/admin/comment_list.html`
|
||
- **修复**: 新部署默认 `UseGravatar=false`(gorm tag default:false 同步);前端注释占位(AuthorInitial + 调色板)已是既有模式;管理员评论列表跟随开关,关闭时不再请求 gravatar.com;管理员可在评论设置页显式重开
|
||
- **说明**: 线上已存在配置行不受 default 迁移影响,后台关闭即可;协议固有反查风险保留(开启者知情)
|
||
- **验证**: ✅ `TestGravatarOffByDefault`、`TestAdminCommentListFollowsGravatarSwitch`(关:无 gravatar.com;开:出现)
|
||
|
||
### [x] 16. RSS 以 Host 头构造 baseURL ✅ 2026-08-27
|
||
- **位置**: `models/site_setting.go`(新增 SiteURL)、`handlers/settings.go`、`templates/admin/settings_site.html`、`handlers/rss.go`
|
||
- **修复**: 站点设置新增规范地址(SiteURL,保存时 trim);RSSFeed 优先使用固定 URL(去尾斜杠),未配置时告警日志 + 回退请求 Host(兼容旧部署)
|
||
- **验证**: ✅ `TestRSSUsesConfiguredSiteURL`(配置后 Host 头污染不生效 / 未配置回退)
|
||
|
||
### [x] 17. bcrypt cost 偏低 ✅ 2026-08-27
|
||
- **位置**: `models/user.go`(SetPassword)、`handlers/login_ratelimit.go`(dummyHash)
|
||
- **修复**: `bcrypt.DefaultCost`(10) → 12(models 包常量 `bcryptCost`,dummy 哈希同 cost);已有哈希自适应不失效,下次改密自然升级
|
||
- **验证**: ✅ 现有认证/限速测试全绿(含 -race);成本升级使登录延迟 ~300ms,配合 #10 限速可接受
|
||
|
||
### [x] 25. 登录计时侧信道(用户名枚举)✅ 2026-08-27(与 #10 一并实施)
|
||
- **位置**: `handlers/auth.go:39-47`
|
||
- **问题**: 用户不存在时立即返回、不执行 bcrypt;密码错误时执行 bcrypt(~100ms)。响应时间差可用于枚举有效用户名,与未修复的 #10(无速率限制)叠加放大。
|
||
- **修复**:
|
||
- [x] 用户不存在时也执行一次 dummy bcrypt 比较(包级预生成哑哈希),抹平时间差;两分支均记录失败计数
|
||
- **验证**: ✅ 结构保证两分支均执行一次 bcrypt(`TestLoginTimingDoesNotRevealUser` 断言未知用户分支进入 Fail);大样本计时统计属人工运维验证,逻辑上两分支 B 树一致
|
||
|
||
---
|
||
|
||
## 不需要修复(已确认安全,2026-08-27 复审复核仍成立)
|
||
|
||
- SQL 注入:全参数化查询(GORM)——唯一例外见 #19(admin_user.go 3 处字符串条件)
|
||
- XSS:html/template 自动转义 + 评论双防御(服务端 strip + DOMPurify)
|
||
- 密码哈希:bcrypt
|
||
- 附件路径穿越:SHA-256 内容寻址文件名
|
||
- 线上默认凭据:已修改(已验证)
|
||
- 注册接口:已关闭(已验证)
|
||
|
||
---
|
||
|
||
## 建议执行顺序
|
||
|
||
全部 25 项(含 P3-14/15/16/17、P3-25)均已修复并验证,仅 #18 方案 A(数据库移出存储根)为可选项:
|
||
|
||
1. (可选)#18 方案 A:数据库文件移出存储根,需迁移存量 blog.db
|
||
2. 持续观察项:#15 Gravatar 开启时的反查风险(管理员知情)、#9 本地化 vendor 库版本升级提醒(随浏览器生态更新,重建 `scripts/build_tailwind.sh` 与 vendor 文件)
|