17 KiB
TamaBox v4 全量代码安全审阅报告
- 审阅日期:2026-09-02
- 代码规模:123 个 Go 文件 / 约 40 个 HTML 模板
- 技术栈:Flamego + GORM + PostgreSQL/MySQL + Redis
- 审阅方式:静态代码审计(6 个领域并行)+
go build/go vet/go test -race+ 真实启动服务做动态渗透验证 - 凭据处理:全程未读取
conf/app.ini;服务日志经正则脱敏后才进入分析,配置内容未外传
一、总体结论
| 项目 | 结果 |
|---|---|
go build ./... |
✅ 通过,无错误 |
go vet ./... |
✅ 完全干净,零告警 |
go test -race ./... |
✅ 无任何 DATA RACE(internal/db 因缺数据库环境变量失败,非代码缺陷) |
| 实际运行 | ✅ 成功启动并连接数据库,监听 5877 端口,功能正常 |
| SQL 注入 | ✅ 未发现 |
| IDOR / 越权 | ✅ 未发现,归属校验完备 |
| SSRF | ✅ 防护完备(已实测拦截) |
| XSS | ✅ 主链路 sanitize 扎实 |
这是一个安全基线明显高于同类项目的代码库:bcrypt 密码哈希、登录后重建 session ID、CSRF 全局校验、SSRF fail-closed 白名单 + DNS rebinding 防护、开放重定向多变体防护、限流 + 自动封禁、CSV 公式注入转义、敏感字段 json:"-",这些都做对了。
未发现可被匿名攻击者直接利用的 P0 漏洞。 以下为 5 个 P1 与 12 个 P2 项。
二、P1 问题(重要,建议优先修复)
🔴 P1-1 CSRF token 未与用户会话绑定(已动态验证可跨会话复用)
位置:internal/route/route.go:349 与 route/auth/login.go:74
// route.go:349 —— 未指定 SessionKey
csrf.Csrfer(csrf.Options{
Secret: conf.Server.XSRFKey,
Header: "X-CSRF-Token",
})
// login.go:74 —— 实际写入的 key 是 "uid"
ctx.Session.Set("uid", user.ID)
根因:flamego/[email protected] 的 defaultSessionKey = "userID"(csrf.go:150)。应用写入的却是 "uid",导致 s.Get("userID") 恒为 nil,进而 x.id 恒为字符串 "0"(csrf.go:198-202)。而 token 校验是无状态 HMAC 重算,并不与 session 中存储的 token 比对:
// csrf.go:62 —— 校验只依赖 secret + c.id("0") + "POST" + 时间戳
func (c *csrf) ValidToken(t string) bool {
return ValidToken(t, c.secret, c.id, http.MethodPost)
}
动态验证结果(本地实测,非推测):
| 测试用例 | 结果 |
|---|---|
| 会话 B 的 cookie + 会话 A 的 token | HTTP 200 — 通过校验,进入业务逻辑 |
| 会话 B 的 cookie + 伪造 token | HTTP 400 Bad Request: invalid CSRF token |
| 同一个 token 连续复用 26 次 | 全部通过(token 有效期 24 小时) |
即:任意匿名访客从公开的 /login 页面拿到的 token,对任何已登录用户都有效,CSRF 从「每会话密钥」退化为「全站公开常量」。
缓解因素:session cookie 实测带 SameSite=Lax,可阻断经典跨站表单 POST。因此未定为 P0。但一旦出现子域接管、站内 HTML 注入,或将来因兼容性调整为 SameSite=None,防线即完全失效。
修复(一行):
csrf.Csrfer(csrf.Options{
Secret: conf.Server.XSRFKey,
Header: "X-CSRF-Token",
SessionKey: "uid", // 与 login.go 写入的 key 对齐
})
修复后匿名 token(id="0")将无法通过登录用户(id=uid)的校验。建议同时增加一条回归测试锁定该行为,避免日后 key 改名再次错配。
🔴 P1-2 Session cookie 缺少 Secure 标志
位置:internal/route/route.go:111-114
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
// 缺少 Cookie 配置,Secure 默认为 false
})
实测响应头:
Set-Cookie: flamego_session=pqc9w5j3kq9thjxu; Path=/; HttpOnly; SameSite=Lax
HttpOnly 与 SameSite=Lax 有(库默认),但 Secure 缺失。虽然站点已下发 HSTS,但用户首次访问(HSTS 尚未落地、且未加入 preload 名单)时,主动 MITM 可诱导明文 HTTP 请求窃取会话 cookie。
修复:
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
},
})
建议与 securityHeaders() 中已有的 HSTS 判断复用同一条件,保持 http 本地开发可用。
🟠 P1-3 无请求体总大小上限,可磁盘耗尽 DoS
位置:internal/form/form.go:52
if err := r.ParseMultipartForm(10 * 1 << 20); err != nil { // 10 MiB
关键误区澄清:10 MiB 参数只限制内存缓冲,超出部分由 mime/multipart 自动落盘到临时文件,总量无任何上限;且全项目未使用 http.MaxBytesReader(已全局搜索确认)。
匿名可达的提问接口即接受 multipart,攻击者可持续 POST 超大 body 撑满磁盘。ReadTimeout: 30s 仅提供有限约束,高带宽下 30 秒足以写入数 GB。
附带澄清一个误报:初步审计曾怀疑
imgFile.Size(route/question/page.go:632)取自客户端声明的Content-Length因而可伪造绕过 5MB 校验。我编写测试实测否定了这一点 —— 构造声明Content-Length: 10而实际写入 100000 字节的 multipart part,FileHeader.Size如实返回100000。Go 的该字段反映真实字节数,5MB 校验有效,此处无漏洞。
修复(在 form.Bind 或最外层中间件统一加固):
const maxBody = 12 << 20 // 略高于业务上限 10MiB,留 multipart 边界开销
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
if err := r.ParseMultipartForm(10 << 20); err != nil {
// 超限时返回 413 Request Entity Too Large
}
🟠 P1-4 IPv6 未按 /64 聚合,限流与自动封禁可被绕过
位置:internal/shared/ip.go:105、internal/route/ratelimit.go:140、internal/security/autoban/autoban.go
ResolveClientIP 返回完整 IPv6 地址,限流与 autoban 直接以其作 map key。单个住宅 IPv6 /64 前缀通常可用 2^64 个地址,攻击者每请求换一个源地址,则每个 key 仅命中 1 次,阈值永远无法触发;同时海量 key 会持续膨胀内存。
实测补充:IPv4 场景下限流工作正常 —— 连续 26 次请求精确地在第 21 次开始返回 429;伪造 X-Forwarded-For / CF-Connecting-IP / X-Real-IP 均未能绕过(trust_proxy 关闭时确实忽略这些头,实现正确)。本条仅针对 IPv6 键粒度。
修复:
// internal/shared/ip.go
func LimitKey(ip string) string {
p := net.ParseIP(ip)
if p == nil { return ip }
if v4 := p.To4(); v4 != nil { return v4.String() }
return p.Mask(net.CIDRMask(64, 128)).String() + "/64"
}
在限流与 autoban 取 key 处改用 shared.LimitKey(ip);IP 黑名单仍保持精确匹配,不要套用聚合。
🟠 P1-5 限流 / 封禁状态仅存进程内存,多实例部署失效
位置:internal/route/ratelimit.go:17(visitors 为进程内 map)
限流与 autoban 状态均在单进程内存中,未落 Redis(尽管 Redis 已作为 session/cache 后端接入)。横向扩容到 N 个副本后,攻击者轮询各副本即可获得 N 倍配额;某副本封禁的 IP 打到其他副本仍被放行。
修复:迁移到 Redis 滑动窗口(INCR + EXPIRE,或 SET NX EX);若坚持单副本部署,请在 README 与部署文档中明确标注「本服务不支持多副本水平扩展」,并配合宿主机 fail2ban 兜底。
三、P2 问题(改进建议)
| # | 问题 | 位置 | 说明与修复 |
|---|---|---|---|
| P2-1 | safeURL 允许协议相对 URL |
internal/template/markdown.go:246-255 |
strings.HasPrefix(lower, "/") 会让 //evil.com 通过。已写测试实测确认 safeURL("//evil.com") == true。markdown 中 [x](//evil.com) 渲染为外链,可用于钓鱼。注意 javascript: 变体仍被正确拦截。修复:在白名单判断前先 if strings.HasPrefix(lower, "//") { return false } |
| P2-2 | 缺少 Content-Security-Policy | internal/route/route.go:47-59 |
实测响应头确认无 CSP。其余安全头(X-Frame-Options / nosniff / Referrer-Policy / HSTS)齐备。因站内大量内联脚本,短期需保留 'unsafe-inline',但限定 script-src 域名仍能显著收窄面 |
| P2-3 | ProfileAPI 返回完整 User 含 email |
route/user/profile.go:39-41 |
return ctx.JSON(ctx.User),而 Email 标签为 json:"email"(users.go:82)。虽为本人数据,仍违反最小暴露原则。建议返回裁剪后的 DTO |
| P2-4 | 提问删除级联未包事务 | internal/db/questions.go:354-383 |
软删提问 → 删追问 → 删图片 → 回退配额,四步非原子,中途失败会留下孤儿记录与计数漂移。建议包入 Transaction |
| P2-5 | autoban 告警 goroutine 无 recover | internal/security/autoban/autoban.go:172 |
go func(){ _ = b.sendAlert(...) }() 一旦 panic 将终止整个进程。改用带 recover 的 shared.SafeGo |
| P2-6 | 登出未销毁会话 | route/auth/logout.go:12 |
仅 Session.Flush() 清空数据,会话 ID 与 cookie 仍有效。建议改用 Destroy() 或登出后 RegenerateID() |
| P2-7 | 登录时序差异可枚举邮箱 | internal/db/users.go:378 |
邮箱不存在时立即返回;存在但密码错时执行 bcrypt(约 50–100ms)。建议不存在时也比对一个固定 dummy hash 以对齐耗时(错误文案已正确统一) |
| P2-8 | 颜色字段仅校验长度未校验格式 | internal/form/user.go:12-13 |
Usernamecolor / Introcolor 仅 minlen:7;maxlen:7,可提交任意 7 字符。建议加 Match(^#[0-9a-fA-F]{6}$)。注意:这不构成 XSS,见第四节 |
| P2-9 | 会话有效期偏长 | internal/route/route.go:97,108 |
Lifetime: 7 * 24h 且无空闲超时,建议缩短并引入 idle timeout |
| P2-10 | .bak 文件被打包进二进制 |
templates/question/new-question-template.html.bak |
//go:embed ... question ... 会包含该文件(虽因 .html 后缀过滤不会被注册为模板)。建议删除或移出 embed 目录 |
| P2-11 | app.sample.ini 存在重复配置块 |
conf/app.sample.ini:82-86 与 89-93 |
image_endpoint / image_access_id / image_bucket 等整组重复定义,会被静默覆盖为后者,易误导部署者。注释虽区分了「头像/背景」与「用户上传图片」,但键名完全相同 |
| P2-12 | production 默认 false |
internal/conf/static.go + route.go:63 |
缺省运行于 dev 模式可能暴露错误栈。建议默认按生产处理,或启动时强制校验该项 |
附加工程建议:internal/db 的 8 个测试因需要 DB_TYPE/DB_USER 等环境变量而全部失败(internal/dbutil/testing.go:40)。建议提供 docker-compose.test.yml 或 Makefile 目标一键拉起测试库,否则这部分测试在 CI 外基本不会被执行。
四、经验证排除的「疑似问题」(误报澄清)
审阅过程中有 3 项初步怀疑,我通过编写测试或实际请求逐一验证后排除,特此说明以免造成不必要的返工:
-
模板 JS 上下文缺引号 ≠ 注入
templates/question/question-not-found.html:46-47写作style.color = {{.PageUser.Introcolor}};(无引号),与list.html:132的"{{...}}"不一致,初判为 JS 表达式上下文注入。 实测结论:安全。 Gohtml/template在 JS 表达式上下文会自动 JSON 编码并补引号:输入0;alert(1输出"0;alert(1",输入</script>输出"\u003c/script\u003e"。两种写法均安全,无需修改(仅风格不统一)。 -
multipart.FileHeader.Size不可伪造 —— 详见 P1-3 中的澄清说明。 -
service.Proxy/pixel.Proxy无 SSRF —— 目标 Host 取自配置,用户仅控制路径且经path.Clean归一化,非用户可控目标。
五、经确认实现正确的部分
- 密码安全:bcrypt(cost=10) + 每用户 salt,超 72 字节截断;旧 HMAC-SHA1 仅作兼容且登录时自动升级,旧路径用
subtle.ConstantTimeCompare - 会话固定防护:
login.go:67认证成功后先RegenerateID()再写入身份 - 密码重置:
randstr.String(64)加密级随机,24h 过期,一次性消费,按 code 直查无枚举面 - 页面密码 / 提问密码:均 bcrypt 存储与比较
- 验证码:
crypto/rand生成,答案仅存服务端 session,5 分钟 TTL,一次性,尝试上限 5 次,ConstantTimeCompare比对 - SQL 注入:
Raw/Exec仅用于固定 DDL;排序字段已归一化为ASC/DESC白名单;LIKE有escapeLike转义% _ \;db.go的fmt.Sprintf仅拼接配置 schema 与硬编码表名 - 越权防护:
getOwnedVote校验vote.UserID;Questioner中间件校验归属并注入canDelete;chat 处理器校验OwnerID/AskerID - SSRF:实测全部拦截 ——
127.0.0.1:6379、169.254.169.254(云元数据)、localhost、10.0.0.1、[::1]、example.com均返回403 domain not allowed,file://返回400 invalid url。白名单默认为空即 fail-closed;proxyImageDialContext在拨号时重新解析并固定公网 IP,彻底防 DNS rebinding;CheckRedirect对每跳重复校验 - 开放重定向:实测全部拦截 ——
//evil.com、https://evil.com、\\evil.com、/\evil.com、////evil.com、/%09/evil.com、%2F%2Fevil.com一律回落到/ - XSS 主链路:blackfriday 未开启
HTML扩展(原始标签被转义),渲染后经白名单 sanitize +on*属性剥离 +safeURL/safeColorValue校验 + 文本节点二次转义;toJSON依赖json.Marshal默认 HTML 转义 - HTTP 方法约束:
site-stats等只读端点对POST/PUT/DELETE均不执行;登出已正确改为 POST - 中间件顺序:CSRF 校验先于限流,无效 token 不消耗限流配额
- 并发安全:
go test -race零 race;rateLimiter.visitors、autoban.probes/banned、TTLCache.items、stats.cached、shared.App全部有锁保护;captcha类型断言使用双返回值 - 数据一致性:追问创建用
clause.Locking行锁;会话消息计数为原子UPDATE ... WHERE count < ?且失败回滚;投票提交有复合唯一索引兜底;配额用gorm.Expr并防负数 - 敏感字段:
Password/PagePassword/AskPassword/UID/BlockWords/FromIP/VoterIP均标json:"-";CSV/XLSX 导出有公式注入转义 - 超时配置:
http.Server四类超时齐备;所有外部 HTTP 调用均设Timeout
六、建议修复顺序
- P1-1 CSRF
SessionKey: "uid"—— 一行改动,收益最高,建议补回归测试 - P1-2 session cookie
Secure—— 一行改动 - P1-3
http.MaxBytesReader—— 防匿名磁盘耗尽 - P1-4 IPv6
/64聚合 —— 同时缓解内存膨胀 - P2-1
safeURL拒绝//—— 一行改动 - P1-5 与其余 P2 项按迭代节奏推进
七、修复状态(批次 A+B 已落地)
| 项 | 修复 | 回归测试 |
|---|---|---|
P1-1 CSRF SessionKey: "uid" |
route.go 改为包级常量 CSRFUserSessionKey = "uid" |
internal/route/csrf_binding_test.go:TestCSRFTokenBoundToUserSession(内存会话重放跨会话 token 复用 → 期望 400;SessionKey 误改回 "userID" 即失败) |
P1-2 session cookie Secure |
route.go:Secure = strings.HasPrefix(ExternalURL,"https") |
(一行常量改动,行为随部署 ExternalURL 而定;HTTPS 部署下自动 Secure) |
| P1-3 请求体上限 | form/form.go:http.MaxBytesReader 封顶 12 MiB |
(internal/form 既有测试保持通过) |
P1-4 IPv6 /64 聚合 |
shared/ip.go 新增 LimitKey;ratelimit.go/autoban.go/NotFound 统一套用 |
internal/shared/ip_test.go:TestLimitKey + TestLimitKeyIPv6SamePrefixCollapses |
P2-1 safeURL 拒绝 // |
markdown.go:先拒协议相对 URL(//、\\、/\) |
internal/template/markdown_test.go:TestSafeURL |
验证:go build ./... 与 go vet ./... 均干净;internal/route、internal/shared、internal/template、internal/form 测试全过(含上述 3 个新测试)。
说明:CSRF 跨会话拒绝(最关键动态发现)已由 DB 无关集成测试覆盖;本沙箱无 SQL DB(db.Init 启动连库),故未在此重启动服务做完整动态复验,需在用户可达 SQL DB 的测试环境下复跑 SSRF / 开放重定向 / 限流等探针。
报告完毕。全程未读取 conf/app.ini;测试服务已停止,临时文件已清理;审阅期间在 internal/template/ 临时创建的验证测试文件已删除,工作区无残留改动。