- topics/sec.md:两轮修复的结论、全局复查新发现(私聊 POST 越权、页面密码 next)、 CSP 白名单口径、字体只能用 glyf 轮廓这条硬限制,以及复现脚本的两种「假红」 - MEMORY.md:安全小节补跳转白名单、GET/POST 校验不对称、CSP/umami、注册防枚举 - 2026-10-27.md / topics/build.md:本地 WSL 部署与验收的具体跑法
4.5 KiB
4.5 KiB
2026-10-26
修复 P1-3:CDN 权威客户端头未校验来源
用户确认:生产/测试 trust_proxy = true,回源是 cloudflared Tunnel 回本机(对端 127.0.0.1);
境内 EdgeOne 已取消(实测期国内速度不理想),将来可能重新启用。
改动
internal/shared/ip.go- 新增
headerSourceTrusted(remote):对端是回环 或 命中trusted_proxies才允许采信权威头。 ResolveClientIP把CF-Connecting-IP/EO-Client-IP读取整体收进该判断;公网对端忽略。- 公网对端带权威头 →
sync.Once一条 WARN(remote_addr+header_ip),覆盖「伪造」与 「CDN 直连回源忘登记网段」两种情形。 - XFF / X-Real-IP 信任范围未变(仍只认
trusted_proxies,回环不隐含信任)。
- 新增
internal/shared/ip_test.go:原 4 个用例按新契约调整;新增RejectsForgedHeaderFromPublicPeer(4 种伪造组合)TrustedProxyHonorsCDNHeaderIPv6LoopbackHeaderLoopbackDoesNotTrustXFF;加setTrustedProxies助手(还原包级状态)。conf/app.sample.ini(CRLF,用脚本按行块改,未破坏行尾)+internal/conf/static.go注释同步。
验证
- 变异验证:把
headerSourceTrusted(remote)临时改成true(旧逻辑)→ 回归用例 3 个子项失败; 还原后全绿。确认测试咬得住。 go build -overlay dev/overlay.json ./...通过;排除internal/storage(overlay 桩缺fitsWithin/aspect等辅助函数,属既有问题)后全包测试通过。
环境坑(值得记)
- 本机
go是 1.25.5,go.mod要 1.27.3;GOSUMDB=off会让工具链校验失败( "verifying module: checksum database disabled by GOSUMDB=off"),GOTOOLCHAIN=local又降级到 1.25.5。 解法:直接调缓存里的工具链~/go/pkg/mod/golang.org/[email protected]/bin/go.exe,配GOTOOLCHAIN=local。 - Python 读 CRLF 文件要显式
newline='',否则 universal newline 把\r\n归一成\n,split('\r\n')只会得到 1 行(踩过一次,误判文件被写坏)。
待办
P1-1(img src 白名单)、P1-2(密码 minlen + 验证码顺序)、P2-3(to 开放重定向)均未动。
修复 P1-1 / P1-2
P1-1 外链图片(internal/template/markdown.go)
- 新增
safeImageSrc():<img src>只允许站内相对路径(无协议部分)或主机命中自有图片出口 (image_bucket_cdn_host/aliyun_bucket_cdn_host/<bucket>.<endpoint>/external_url主机)。 - 含反斜杠一律拒(
\在浏览器等价/);//先于「以 / 开头=站内」判断;url.Parse+Hostname()比对 → 后缀 / userinfo 把戏失效。 img无合法 src 时整段丢弃(只删 src 会留<img alt>变成破图)。a[href]仍走safeURL(外链需点击,产品上要允许)。- 测试:
TestSafeImageSrc(12 条拒绝)+TestMarkdownToHTML_ImageSource(端到端); 变异验证:safeImageSrc直接 return true → 7 条拒绝用例失败。
P1-2 密码(5 个文件)
minlen:6+form.PasswordMinLen;handler 里rejectTooShortPassword()二次校验(rune 计数); 前端minlength=6 maxlength=30+ 提示文案「6–30 位,留空则关闭」;i18n 新增form.profile.passwordLen/question.errPasswordLocked(4 语言)。- 校验顺序:抽
askPasswordDenied(),从New()首行挪到两个分支验证码之后(私聊 + 普通提问各一次)。 没把验证码提到最前——会让「箱已关闭」「需登录」变成验证码错误。 - 失败锁定:新增
shared.AttemptLimiter(无后台 goroutine,惰性清理 + 键数上限);pagePasswordGuard(5, 10min, 10min),键 = 箱主ID + LimitKey(IP)。锁定期内不比对密码。
端到端验收脚本
outputs/sec-audit-2026-10-25/verify_p1_fixes.py(--only p1-1,p1-2a,p1-2b,p1-2c)。
在未部署的旧版本上四项全 FAIL,已实测 → 证明检测有效;部署后应全 PASS。
踩到的两个判定坑(写脚本时记下来):
- 提问成功页是 200(不是 302),不能用状态码判断提交成功。
- 受保护的箱是在
/_/<domain>内联渲染密码表单(200),不是跳转/password; 判断「有没有密码闸门」只能看页面里有没有action="/_/<domain>/password"。 - 箱主页与
/api/v1/user/<domain>/questions都不暴露题目 id(无后台), 要定位刚建的提问只能扫/_/<domain>/<id>(脚本里question_ids_with_marker)。