# TamaBox-v4 修复后代码复审报告 - **复审时间**:2026-08-26 - **审阅对象**:TamaBox-v4 - **复审基线**:`outputs/TamaBox-v4-code-review-report-2026-08-26.md` - **复审范围**:上一轮 4 项确认问题的修复调用链、相关数据库模型、模板与回归测试;并对修复影响面做定向静态复查。 - **结论口径**:本报告仅将能由当前代码直接确认的问题列为确认问题;环境限制与建议性事项单独列出。 ## 一、复审结论 上一轮报告中的 4 项确认问题均已完成代码级修复,未发现原问题仍可沿原路径复现: | 原问题 | 复审结论 | 关键位置 | |---|---|---| | P1-01 匿名 `vote_fp` Cookie 可伪造 | **已修复** | `route/vote/vote.go:62-120, 691-700` | | P1-02 Markdown 实体编码导致 XSS | **已修复** | `internal/template/markdown.go:143-147` | | P2-01 多选重复选项可重复计票 | **已修复** | `route/vote/vote.go:232-247, 749-775, 802-809` | | P2-02 投票次数配置与一人一票约束矛盾 | **已修复** | `route/user/vote.go:199-202, 563-566`、`templates/user/vote-form.html:85-88` | 本轮复审另发现并修复了一个由 P1-01 修复引入的兼容性问题:签名 token 长度为 108 字符,原数据库字段 `size:64` 不足,已调整为 `size:128`(`internal/db/vote_submissions.go:54-55`)。 **当前代码级结论:未发现 P0/P1/P2 确认问题。** ## 二、逐项复审证据 ### 1. P1-01:匿名指纹 Cookie 伪造与防刷绕过 **状态:已修复。** - `newVoterToken` 生成 32 字节随机 nonce,格式为 `64 位十六进制 nonce + "." + HMAC-SHA256 签名`。 - `signVoterToken` 使用 `conf.Server.XSRFKey` 作为服务端密钥;配置初始化已强制要求该密钥非空。 - `validVoterToken` 检查分段数量、nonce 长度、十六进制格式,并使用 `hmac.Equal` 比较签名。 - `ensureVoterToken` 对无效/伪造 Cookie 重新签发;`currentVoterToken` 对无效 Cookie 返回空值,不再将客户端任意字符串作为身份依据。 - 匿名提交无论是否带有效 Cookie,均按投票维度执行 24 小时 IP 计数;当前一人一票策略下,同一投票同一 IP 窗口内最多接受一次匿名提交。 - 数据库 `UNIQUE(vote_id, voter_token)` 仍作为有效 token 的最终并发防重兜底。 **残余设计取舍**:IP 兜底会对 NAT、校园网、公司网络等共享出口产生误伤;这是保守防刷策略,不是身份伪造漏洞。若业务必须支持共享出口多人投票,应改用验证码、设备/行为风控或更细粒度限流,而不能取消服务端限流。 ### 2. P1-02:Markdown 实体编码 XSS **状态:已修复。** - `sanitizeHTML` 的 `html.TextToken` 分支现在对 tokenizer 已解码的文本调用 `html.EscapeString` 后再写入 `template.HTML`。 - 因此 `<script>`、双重编码输入不会在重建 HTML 时重新变成可执行标签。 - 标签、属性、URL 白名单逻辑保持有效;事件属性和危险协议仍被过滤。 - 回归测试已覆盖实体编码、双重编码、script、事件属性、危险 URL、iframe、SVG、style 以及 font color 属性注入。 ### 3. P2-01:多选重复 option ID 重复计票 **状态:已修复。** - `parseMultipleOptionIDs` 统一负责多选参数解析。 - 解析时使用 `seen` 集合;同一 `option_id` 出现两次直接返回“选项不能重复选择”。 - 同时校验空提交、最大选项数、选项归属和 `uint` 溢出,避免异常数值绕过校验。 - `LoadResults` 的多选聚合层也对历史数据中的重复 option ID 做防御性去重,因此旧异常数据不会在每次结果计算中再次放大统计。 - 新增测试覆盖正常多选、重复选项、超出上限、无效选项和整数溢出。 ### 4. P2-02:MaxVotesPerFingerprint 与数据库约束矛盾 **状态:已修复。** - 当前数据模型明确采用一人一票:登录用户由 `UNIQUE(vote_id, user_id)` 约束,匿名用户由 `UNIQUE(vote_id, voter_token)` 与 IP 兜底共同保护。 - 管理端解析不再信任旧页面或手工请求提交的 `max_votes_per_fingerprint`,统一按 `1` 保存。 - 新建/编辑失败回填也固定显示 `1`;表单输入设置为 `readonly`、`max="1"`,避免继续传达可配置多票语义。 - 四种语言的字段文案已改为明确的一人一票含义。 ## 三、本轮新增兼容性问题及修复 签名 token 的组成长度为:64 字符 nonce + 1 个分隔符 + 43 字符 Raw Base64URL HMAC = **108 字符**。复审过程中确认原字段: ```go VoterToken *string `gorm:"size:64;..."` ``` 不足以存储完整 token,可能导致 MySQL 严格模式下匿名提交失败或 token 截断。现已改为: ```go VoterToken *string `gorm:"size:128;uniqueIndex:idx_submission_token"` ``` 并加入 token 长度回归断言。部署时依赖启动阶段的 GORM AutoMigrate 将字段扩容;上线前应在预生产数据库确认迁移结果和唯一索引状态。 ## 四、验证结果 ### 通过 ```text gofmt -d route/vote/vote.go route/vote/vote_stats_test.go route/user/vote.go \ internal/db/vote_submissions.go # 无输出,格式通过 go test -count=1 ./route/vote ./internal/template ./templates ./internal/i18n \ ./internal/security/autoban ./internal/security/censor ./internal/imagecheck \ ./internal/shared ./internal/cache # 全部 PASS go vet ./route/vote ./internal/template ./templates ./internal/i18n # 无输出,检查通过 ``` ### 全量验证限制 ```text go test ./... go vet ./... ``` 未能全绿,原因是当前环境已有的基础设施限制,不是本轮修复代码引入: 1. `github.com/chai2010/webp@v1.4.0` 在当前 Windows 环境缺少 CGO/WebP 编译符号(如 `webpGetInfo`、`webpDecodeRGB`),导致 `internal/storage`、`route/question`、`route/user`、主程序等相关包无法完整编译。 2. `internal/db` 测试未配置数据库类型,测试初始化报 `Unknown database type: ""`。 因此仍需在生产同构的 Linux/CGO 构建环境,并配置测试数据库后执行: ```text go test ./... go vet ./... govulncheck ./... ``` ## 五、上线前建议 1. 在预生产数据库执行/确认 AutoMigrate,确认 `vote_submissions.voter_token` 已扩容到至少 128 字符,且唯一索引仍存在。 2. 使用两个不同匿名客户端验证:同一 IP 同一投票第二次提交被拦截;伪造 `vote_fp` 会被覆盖且不能绕过 IP 限制;正常匿名首次投票成功。 3. 用真实浏览器验证实体编码文本仅以文字显示,不执行脚本;覆盖提问、回答、投票描述和结果页等 Markdown 输出点。 4. 若站点存在大量共享出口,结合验证码或行为风控评估 IP 一票策略的误伤率。 ## 六、修改文件清单 - `internal/template/markdown.go` - `internal/template/markdown_test.go` - `route/vote/vote.go` - `route/vote/vote_stats_test.go` - `route/user/vote.go` - `templates/user/vote-form.html` - `internal/db/vote_submissions.go` - `internal/i18n/dict_zh_cn.go` - `internal/i18n/dict_zh_tw.go` - `internal/i18n/dict_en.go` - `internal/i18n/dict_ja.go`