- 新增 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,处置覆盖面一条标注投票链路已补齐
11 KiB
安全评估与客户端 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.oneCNAME 到第三方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>),缺口只在跳转参数。