- topics/sec.md:两轮修复的结论、全局复查新发现(私聊 POST 越权、页面密码 next)、 CSP 白名单口径、字体只能用 glyf 轮廓这条硬限制,以及复现脚本的两种「假红」 - MEMORY.md:安全小节补跳转白名单、GET/POST 校验不对称、CSP/umami、注册防枚举 - 2026-10-27.md / topics/build.md:本地 WSL 部署与验收的具体跑法
128 lines
10 KiB
Markdown
128 lines
10 KiB
Markdown
# 安全评估与客户端 IP 信任
|
||
|
||
报告:`outputs/security-audit-2026-10-25.md`;复现脚本 `outputs/sec-audit-2026-10-25/`(可重跑)。
|
||
|
||
## 客户端 IP 采信(P1-3,2026-10-26 已修)
|
||
|
||
- 唯一读取点:`internal/shared/ip.go` 的 `ResolveClientIP`,23 处调用共用。
|
||
**不要在别处另读 `CF-Connecting-IP` / `EO-Client-IP`**,否则绕过校验。
|
||
- 规则:权威头只在 `headerSourceTrusted(remote)` 为真时采信 ——
|
||
对端是回环地址,或命中 `trusted_proxies`。公网对端带来的权威头一律忽略,回退 remote。
|
||
- XFF / X-Real-IP 仍只认 `trusted_proxies`;回环**不**隐含信任这两个头。
|
||
- 公网对端却带权威头 → 进程内一条 WARN(`remote_addr` + `header_ip`)。
|
||
出现这条日志 = 要么有人在伪造,要么 CDN 直连回源但没登记网段。
|
||
- 不能靠「哪个头存在」推断请求走了哪家 CDN:客户端自带任意头都能反向利用。
|
||
|
||
## 部署形态
|
||
|
||
- 生产/测试都是 `trust_proxy = true`,回源 = **cloudflared Tunnel 回本机**(对端 127.0.0.1),
|
||
因此修复后零配置变更。
|
||
- **EdgeOne 已取消**(境内访问速度不理想),当前只有 Cloudflare 一条线。
|
||
重新启用前必须把 EO 回源网段写进 `[security] trusted_proxies`,否则 EO 线退化成按边缘 IP 聚合
|
||
(限流互挤、自动封禁误伤整片)。注释已写进 `conf/app.sample.ini`。
|
||
- `box.shiroko.one` CNAME 到第三方 `cloudflare.182682.xyz`(CF SaaS 优选),该 zone 的
|
||
Transform/WAF 不归我们管 —— 别把安全前提建立在那边的规则上。
|
||
- 源站隐藏:外网尽力排查(A 记录 / certspotter 51 个子域 / MX / 候选 IP)未找到直连入口;
|
||
仍须服务器侧 `dev/security-selfcheck.sh local` 确认 `listen = 127.0.0.1:5877`。
|
||
|
||
## 实测确认「防护到位」(别重复怀疑)
|
||
|
||
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭(含实体编码、`java\tscript:`、
|
||
协议相对 `//`、反斜杠);跨箱题目/投票越权全 302;页面密码对主页/整箱分享图/单题分享图/API
|
||
四个出口都生效;pixel/service/ShareCard 无 SSRF;`exec cwebp` 不接用户输入;SMTP 默认校验证书;
|
||
`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;无 CORS 头;测试站蜜罐已开。
|
||
|
||
## 已修(2026-10-26)
|
||
|
||
- **P1-1** 外链 `<img>`:`markdown.go` 的 `safeImageSrc` 只放行站内相对路径与自有图片 CDN 主机
|
||
(配置来源 `image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url`);
|
||
`a[href]` 仍允许外站。无合法 src 的 img 整段丢弃。
|
||
- **P1-2** 密码:page/ask 密码 `minlen:6`(`form.PasswordMinLen`)+ handler 二次校验;
|
||
提问密码比对挪到验证码之后(`askPasswordDenied`,私聊与普通提问各调一次);
|
||
页面密码失败锁定 `shared.AttemptLimiter(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。
|
||
- **P1-3** 权威头:见上「客户端 IP 采信」。
|
||
- 部署后验收:`python outputs/sec-audit-2026-10-25/verify_p1_fixes.py`,四项应全 PASS
|
||
(旧版本上实测四项全 FAIL)。跑完会自动还原严格度/密码/测试提问。
|
||
|
||
## 第二轮修复(2026-10-27,P2/P3 全清)
|
||
|
||
- **跳转白名单收敛为唯一实现 `shared.SafeRedirectTarget`**(`internal/shared/redirect.go`),
|
||
登录 `to` 与 `/_/<domain>/password` 的 `next` 共用。规则:拒绝空/含 `\`、控制字符、空格;
|
||
必须单斜杠开头、非 `//`;`url.Parse` 后不得有 Scheme/Host/User/Opaque;遍历 `u.Path` 的 rune
|
||
拒 `\`、`<0x20`、`0x7f`(**关键**:`url.Parse` 会把 `%0d%0a` 解码成 CRLF,只看原始串抓不住);
|
||
最后 `path.Clean` 再验一次前缀。测试在 `redirect_test.go`(25 条定向 + 全量不变式)。
|
||
- **注册防枚举**:邮箱重复/已停用与全新注册走**同一个** `registerSucceeded(ctx)` 出口
|
||
(302 → `/login`、无 alert),日志里才留痕。域名占用仍明文提示(不然用户没法选)。
|
||
代价:忘密用户只能走「找回密码」。
|
||
- **CSP**:`internal/route/csp.go` + `[security] csp = off|report|enforce`(默认 report)、
|
||
`csp_report_uri`。`script-src` 白名单必须含 **`https://mami.shiroko.one`**——
|
||
`templates/base/header.html` 用 `document.createElement("script")` 动态注 umami,
|
||
静态 grep `<script src=` 抓不到,切 enforce 后会在无头 Chrome 里报违规。
|
||
存储 API endpoint(`r2.cloudflarestorage.com` 等)**不要**写进 CSP,只登记对外发资源的 CDN 域。
|
||
- **`no-store`**:`/user`、`/_*` 命中 `isNoStorePath`,`/share-card` 例外(要能被爬)。
|
||
- **`securityHeaders` 注册顺序**:原先排在静态资源中间件之后,静态文件拿不到 nosniff/HSTS,已前移。
|
||
- **封禁落库** `internal/db/ban_record.go`(`ban_records`:ban_key/reason/duration_minutes/expires_at),
|
||
`newBanner()` 的 `OnBan` 异步写(3s 独立 ctx,失败只告警,store 为 nil 直接跳过)。
|
||
- 其它:token 比较改 `subtle.ConstantTimeCompare`;`/explore` 页码上限 500;
|
||
429 统一 `tooManyRequests()`(带 `Retry-After`);`ctx.JSON/JSONError` 显式补 Content-Type。
|
||
|
||
## 全局复查新发现(2026-10-27)
|
||
|
||
- 🔴 **私聊 `SendMessage` 缺所有权校验**(高危):`internal/route/chat/chat_handler.go` 只有 GET 分支
|
||
校验 `conv.OwnerID != pageUser.ID`,POST 没有 → 被拉黑的人换个 domain 就能继续往他人会话发消息、
|
||
触发邮件、绕过屏蔽词与日限额。已补校验 + `verify_chat_owner.py` 端到端 3/3 + **变异验证**
|
||
(去掉校验后「换 domain 不能操作他人的会话」立刻变红)。**教训:同一 handler 里 GET 有校验
|
||
不代表 POST 有,逐分支核对。**
|
||
- 🟡 页面密码 `next` 与 P2-3 同源缺陷(`/\evil` 外跳),随白名单收敛一并修好。
|
||
- 🟡 **分享卡字体**:无可用字体时每个请求要烧 0.7–4s CPU,而 `/share-card` 是公开端点。
|
||
现在成功永久缓存、失败缓存 1 分钟(`fontFailRetry`);实测连发 3.44s → 0.53s。
|
||
|
||
### 分享卡字体的硬限制
|
||
|
||
`github.com/golang/freetype` **只支持 TrueType(glyf)轮廓**,CFF/OTTO 一律报 `bad TTF version`。
|
||
`NotoSansCJK-Regular.ttc` 是 OTTO → 就算 `ttcFirstFont()` 正确提取出子字体也仍不可用。
|
||
`.ttc` 是字体集合,需先按 `ttcf` 魔数 → `numFonts` → 子字体偏移 → 重排 sfnt header + 表目录
|
||
(4 字节对齐)才能交给 freetype(`route/question/ttc.go`,测试 `ttc_test.go` 自造最小 TTC)。
|
||
生产机上要备一个 **glyf 轮廓**的 TTF(如 LXGWWenKai-Medium.ttf)放 `static/fonts/` 或配 `font_path`。
|
||
|
||
## 仍待决策/未做
|
||
|
||
- 限流/失败计数迁 Redis(单进程内存态,多副本会各算各的)—— 明确不做,配置注释已写明单副本前提。
|
||
- 匿名投票清 cookie 即换身份:只加了结果页匿名说明,未做 IP 唯一索引(会被 NAT/移动网误伤)。
|
||
- 颜色字段只校验长度(有 Go 上下文转义兜底,非安全问题)。
|
||
- P1-1 残余:存量内容里的外链 img 仍在库里,仅渲染层不再输出;将来若加「原样导出」入口会复发。
|
||
- CSP 目前停在 `report`:7 个公开页面实测零违规,可以切 `enforce`,等用户拍板。
|
||
|
||
## 复现脚本怎么跑(踩过的两次假红,别再误判成回归)
|
||
|
||
- 四套脚本默认目标是**远端** `box-dev-api`(`audit.py` 的 fallback),在本地实例上验证
|
||
必须显式给环境变量,否则 4/4 全 FAIL —— 那是「远端没部署修复」,不是回归:
|
||
`PYTHONDONTWRITEBYTECODE=1 TB_BASE=http://172.27.49.117:5877 [email protected]
|
||
TB_PWD='TbLocal!2026' TB_DOMAIN=tblocal <脚本>`(Windows 侧 127.0.0.1:5877 被占,需用 WSL eth0 IP)。
|
||
- **`verify_chat_owner.py` 必须在 WSL 里跑**:它直接调 `psql` 查库,Windows 侧 `psql` 会连到
|
||
宿主自己的 PostgreSQL 18(`/c/OtherFiles/PostgreSQL/18/bin/psql`),账号/库都不对;且 psql 的中文
|
||
报错是 GBK,Python 按 UTF-8 解会先抛 `UnicodeDecodeError` 把真实错误吃掉。WSL 侧:
|
||
`cd /mnt/c/.../sec-audit-2026-10-25 && TB_BASE=http://127.0.0.1:5877 ... python3 verify_chat_owner.py`。
|
||
- 页面密码入口限流 **10 次/分钟/IP**,`verify_page_next.py` 一次要 POST 6 次 → **一分钟内连跑两次**
|
||
会从第 5 项开始 `status=429`;`verify_p1_fixes.py` 的 p1-2c 还会把「本机 IP + 测试箱」锁 10 分钟。
|
||
两者都靠 `systemctl restart tamabox-test` 清内存态。顺序:先 `verify_page_next.py`,再 `verify_p1_fixes.py`。
|
||
- 判据别写「Location 里不含 evil」这种弱断言:一旦被限流/锁定停在密码页也会假绿。
|
||
`verify_page_next.py` 已改成精确比对 `Location == /_/<domain>`、429 单独报,且**每条载荷开新会话**
|
||
(验证通过会给会话发通行标记,复用会话的话后面几条根本不走密码校验)。
|
||
- 残留:`verify_round2.py` / `verify_chat_owner.py` 会注册临时账号(`verify*@example.com`);
|
||
跑完 `DELETE FROM nekobox.users WHERE email LIKE 'verify%@example.com'`(无 FK,先确认
|
||
conversations/questions/votes 无关联行)。
|
||
|
||
## 本地部署实测(2026-10-27,WSL)
|
||
|
||
- 三项 P1 在真实实例上端到端复验通过:P1-1 正/负与变体、P1-2a/b/c、P1-3 的可信/不可信对端 A/B
|
||
(探针用临时 `ip_blacklist = 1.2.3.4`:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 302 命中;
|
||
跨主机对端同样头 → 200 忽略,日志出 `remote_addr` / `header_ip` 的 WARN)。
|
||
- ⚠️ **P1-1 白名单的坑(已修)**:配置里 `image_bucket_cdn_host` 实际写成 `host/path`
|
||
(本项目就是 `r2.337845818.best/tamabox`),整串当主机名比 → 自家图被剥。
|
||
现在统一走 `normalizeHostLike()`(补协议 → url.Parse → Hostname),并把 S3 endpoint 主机自身也入白名单
|
||
(R2 path-style 链接是 `<endpoint>/<bucket>/…`)。**白名单类单测必须照抄生产配置写法。**
|
||
- **P2-3 实测确认可利用**:`/login?to=/\evil.example` → `Location: /\evil.example`,
|
||
WHATWG 解析为 `http://evil.example/`;`\\evil.example` 同样外跳;`//`、`///` 已被规整。
|
||
正文渲染侧同类载荷是安全的(`<a href="/\example.org">` → `<a>`),缺口只在跳转参数。
|