- #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 并更新执行顺序
16 KiB
16 KiB
安全修复 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。
- 修复:
generateSecret()改用crypto/rand生成 32 字节随机数- 已有配置加载路径中 secret 为空时:拒绝启动(
log.Fatalf),不再静默回退;配置文件读取失败也改为直接退出 - 首次生成配置文件时写入强随机 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 差异)。
- 修复:
- 新增
middleware/csrf.go:同步器令牌模式(session 存储、常量时间比较),表单_csrf字段或X-CSRF-Token头二选一,不匹配返回 403 - 覆盖全部 30 个 POST 表单(含游客评论表单);AJAX(附件上传/删除、头像上传)经
<meta name="csrf-token">下发 token 并以请求头携带 /article/:slug/comments游客 POST 一并覆盖(游客同样有 session)
- 新增
- 验证: ✅
middleware/csrf_test.go7 用例 + 端到端 curl 冒烟(无 token/伪造 token 403,有效 token 302)
[x] 3. 附件接口越权(IDOR)✅ 2026-08-19
- 位置:
handlers/attachment.go(DeleteAttachment / ListAttachments / UploadAttachment 的 article_id) - 问题:
/my/articles/attachments/*仅要求登录,无所有权校验;任意登录用户可删除/列出全站任意附件、向他人文章挂附件。 - 修复:
DeleteAttachment:admin / 上传者 / 所属文章作者三者之一,否则 403ListAttachments:文章作者或 admin,否则 403UploadAttachment:article_id != 0时校验文章归属(admin 除外),否则 403- 单元测试(
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,固定攻击可劫持登录后会话。
- 修复:
- 认证成功后先
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,白名单挂载):
main.go新增registerUploadRoutes:只挂载attachments/、avatars/、logos/及当前配置的附件存储目录,存储根不再整体暴露- 自定义
serveUploadDirhandler:禁用目录列表(目录/../\一律 404),只服务具体文件 safeStorageDir:storage_dir 含穿越/绝对路径/反斜杠时回退attachments(为 #22 提供纵深防御)- (可选加固)方案 A:将数据库文件移出存储根,需迁移存量 blog.db,暂缓
- 验证: ✅
main_test.go4 用例(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.1statement.go:293-303已核实)。GET /admin/users/1 OR 1=1/edit即可注入;UserEditPage 为 GET 无 CSRF 拦截,可诱导已登录管理员点击链接触发盲注。 - 修复:
- UserEditPage / UserUpdate / UserDelete 三处:先
uintFormID解析为数值,非数值(0)直接 302 回列表页;查询一律走主键参数化 - 表单 Action URL 由解析后的数值 ID 构造,不再回拼原始路由参数
- UserEditPage / UserUpdate / UserDelete 三处:先
- 验证: ✅
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) - 修复:
- store 默认
SameSite: Lax;Secure按请求动态设置(middleware/https.go检测 TLS 或 X-Forwarded-Proto),通过中间件在每次请求时应用到 session cookie comment_uid游客 cookie 同步补齐SameSite=Lax+ HTTPS 下Secure
- store 默认
- 验证: ✅ 模拟 HTTPS 请求响应头
Set-Cookie: ... HttpOnly; Secure; SameSite=Lax;冒烟测试通过
[x] 6. 缺失安全响应头 ✅ 2026-08-19
- 位置: 新增
middleware/security_headers.go(全局第一个注册) - 修复:
Content-Security-Policy(default-src 'self' + 现有 CDN 白名单 + frame-ancestors 'none' 等)X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Referrer-Policy、Permissions-PolicyStrict-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 - 修复:
- 删除手动解析 XFF 首值逻辑,
GetClientIP改为c.ClientIP() router.SetTrustedProxies(cfg.Web.TrustedProxies);新增web.trusted_proxies配置项(默认["127.0.0.1", "::1"],unix socket 部署自动信任)- gin 内部 XFF 从右往左取第一个不可信 IP:直接客户端伪造的 XFF 被忽略
- 删除手动解析 XFF 首值逻辑,
- 验证: ✅
middleware/clientip_test.go(直接连接带假 XFF 取真实 IP / 代理链取最右不可信条目)
[x] 8. goroutine 数据竞争(use-after-return)✅ 2026-08-19
- 位置:
handlers/home.go(ArticleDetail → recordArticleView) - 修复:
- goroutine 启动前同步提取 userID / ip / UA 为局部变量,
recordArticleView不再触碰 gin.Context 与 session
- goroutine 启动前同步提取 userID / ip / UA 为局部变量,
- 验证: ✅
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。 - 修复:
AuthRequired(db)回库校验Status == StatusNormal且未软删,失败则清 session(保留 lang 与 csrf_token)并跳转 /login;session user_id 一律先断言为数值再入 GORM(呼应 #19)- 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/同源可执行(CSPscript-src 'self' 'unsafe-inline'放行)。 - 修复:
- UploadAvatar / UpdateProfile 头像分支强制
check.Type.Category == models.CategoryImage,解码失败直接拒绝(不再回退存原始字节) - UpdateProfile 头像同样经 processAvatar 解码→缩放→JPEG 重编码,原始字节不再落盘
- addUploadFileType 增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs),拒绝添加并提示(settings_upload 页新增错误提示 + i18n)
- 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠"失败回退"掩盖),补 blank import
- UploadAvatar / UpdateProfile 头像分支强制
- 验证: ✅
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'
- 将 marked / DOMPurify / highlight.js / cropperjs / easymde 下载到
- 验证: 断网第三方域名后页面渲染功能完整;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_-]+$),否则拒绝保存
- 校验 storage_dir 为单个安全路径段(如
- 验证: 提交
../evil→ 拒绝;attachments→ 正常
[ ] 23. 密码策略缺失(改密/管理员建号无最小长度)(2026-08-27 复审新发现)
- 位置:
handlers/profile.go:146-159(UpdateProfile 改密)、handlers/admin_user.goUserCreate/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)——唯一例外见 #19(admin_user.go 3 处字符串条件)
- XSS:html/template 自动转义 + 评论双防御(服务端 strip + DOMPurify)
- 密码哈希:bcrypt
- 附件路径穿越:SHA-256 内容寻址文件名
- 线上默认凭据:已修改(已验证)
- 注册接口:已关闭(已验证)
建议执行顺序
P0/P1 原有 8 项及 P0/P1 新发现 #18–#21 均已完成。剩余:
- #22 → #23 → #24(校验类小改动,可合并一个 PR)
- #9 → #10 → #25(CDN 本地化、登录限速 + 计时抹平,同一主题)
- 其余 P2/P3(#11 配置权限、#12 首启弱凭据、#13 socket 权限、#14 magic bytes、#17 bcrypt cost)按迭代排入
- (可选)#18 方案 A:数据库文件移出存储根