Files
tamabox/.workbuddy/memory/topics/sec.md
T
tamakyi d4e6da76f1 docs(memory): 记录第二轮复查结论与六项修复的验证方式
- 新增 topics/quality.md:业务正确性 / 运维 / 前端体验四类结论,含
  「私聊取最早 50 条」「Classic() 的 Logger 记 RequestURI」这两个坑的成因,
  以及处置覆盖面、Redis 两种故障模式、产品缺口(无 page view / 提问者找不回自己的提问)
- 2026-10-29.md:本轮六项修复的改动与验证结果,另记两个实现坑
  (limit 兜底不该抬大用户配置;Python bytes 字面量不支持 \uXXXX)
- topics/sec.md / 2026-10-27.md:补上一轮遗漏的字体自动发现与 CSP enforce 结论
- MEMORY.md:索引加 quality,处置覆盖面一条标注投票链路已补齐
2026-10-29 15:53:57 +08:00

11 KiB
Raw Blame History

安全评估与客户端 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)。

2026-10-27 已收敛:fontCandidates() 补 glyf 系统候选(wqy-zenhei / wqy-microhei / DroidSansFallbackFull / uming)且排在 Noto(CFF) 之前;static/fonts 同时按 CWD 与可执行文件目录找; CheckShareCardFont() 在 runWeb 启动时探一次(Info 打路径 / WARN 给 apt install fonts-wqy-zenhei)。 本地实测该 apt 包即可零配置出图(/share-card 500 → 200 image/jpeg)。生产同理,别用 Noto CJK。

仍待决策/未做

  • 限流/失败计数迁 Redis(单进程内存态,多副本会各算各的)—— 明确不做,配置注释已写明单副本前提。
  • 匿名投票清 cookie 即换身份:只加了结果页匿名说明,未做 IP 唯一索引(会被 NAT/移动网误伤)。
  • 颜色字段只校验长度(有 Go 上下文转义兜底,非安全问题)。
  • P1-1 残余:存量内容里的外链 img 仍在库里,仅渲染层不再输出;将来若加「原样导出」入口会复发。
  • CSP 默认 enforce(2026-10-27 定):不给 csp_report_uri 的 report-only 等于没装, 留个不生效的开关更容易让人误以为已防护。白名单由 csp_sweep.py 真浏览器巡检背书 (18 页面 + 2 图片端点,零违规);误伤时的逃生口是 csp = report|off + 重启,不用改代码。
  • Sentry CDN 是条件白名单:header.html 的 Sentry 在 {{ if ne SentryDSN "" }} 里, 静态 grep 和本地巡检都碰不到 → 按 conf.App.SentryDSN 条件放行,单测双向断言。

复现脚本怎么跑(踩过的两次假红,别再误判成回归)

  • 四套脚本默认目标是远端 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>),缺口只在跳转参数。