Files
tamabox/.workbuddy/memory/2026-10-26.md
T
tamakyi 430c6ef6d8 docs(memory): 记录审计结论(CSP 默认 enforce、分享卡字体限制、复查新发现)
- topics/sec.md:两轮修复的结论、全局复查新发现(私聊 POST 越权、页面密码 next)、
  CSP 白名单口径、字体只能用 glyf 轮廓这条硬限制,以及复现脚本的两种「假红」
- MEMORY.md:安全小节补跳转白名单、GET/POST 校验不对称、CSP/umami、注册防枚举
- 2026-10-27.md / topics/build.md:本地 WSL 部署与验收的具体跑法
2026-10-27 12:37:55 +08:00

68 lines
4.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 种伪造组合)`TrustedProxyHonorsCDNHeader`
`IPv6LoopbackHeader` `LoopbackDoesNotTrustXFF`;加 `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`)。