Files
tamabox/.workbuddy/memory/2026-10-25.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

8.8 KiB
Raw Blame History

2026-10-25

/stats 文案清理:把「写给运营者看」的话删掉

用户指出「数据速览」下面那句「下面几句是从上面的数字直接读出来的,没有推测」怪 —— 那是对他自己说的(作者视角的免责),不是对访客说的。顺着扫了页面全部 40 余条文案。

改动:

  • 删 stats.insight.note(四语言 + 模板那行 <p class="tb-card-note">)。 卡片留「数据速览」+ 列表即可;stats.insight.empty 保留。 删掉说明行后标题与列表之间只剩 4px,给 .tb-insights 补 margin: 8px 0 0。
  • stats.foot.cacheNote 去掉实现细节:「当天重算一次的快照(当天第一次访问时算, 服务重启也会重算)」→「累计数字与图表每天更新一次」。访客不需要知道缓存是懒重算、 也不需要知道重启会触发重算;准确的新鲜度由「快照时间」那行给出。
  • 顺手修了 templates/stats.html 里一条过期注释(还写着「四条时间」「基准日」, 上一轮已改成三行「快照时间」)。

保留未动(判断过,不属于这个问题):

  • stats.foot.privacy 与顶部 stats.lead 的后半句近乎同义(隐私声明重复一次)—— 页脚隐私声明是惯例,重复不算噪音,故保留。
  • stats.today.tag 的「(实时)」在 60s TTL 下仍成立,不改。

约定(已写进 topics/stats.md)

页面文案一律以访客为第二人称对象:不写实现细节(缓存、重启、索引), 不写作者视角的免责(「没有推测」「这里只有」「本文」这类把读者当审稿人的话)。 数据来源/隐私/新鲜度这些确实要对访客交代的事,放 stats.lead 与页脚注。

环比句改成「点明月份 + 条数」

用户问「上月提问量环比 -75.0%」是不是「上月同期比本月同期」。不是——实际比的是 两个完整自然月(9 月 vs 8 月)。但这个问题本身就是证据:句子里「上月」和「环比」 两个相对词叠着,读者得先做两次翻译,很容易读成「同期对比」。

  • 新句:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。 EN:September had 5,620 questions; August had 4,890, up 14.9%.
  • 口径没变(仍是上月 vs 上上月,本月没过完不参与),只换表达。
  • page.go 新增 monthText:CJK 一律「9 月」,英文用 time.Month().String() ("month 9" 不是英语),不带年份——相比的两个月相邻。
  • 三个词条 momUp/momDown/momFlat 取代 mom:方向词没法塞进同一个模板, 四种语言语序不同。参数顺序统一(本月份名/条数、对比月名/条数、|pct|)。
  • |pct| < 0.05 走持平,避免「多了 0.0%」。
  • 测试:TestInsightMoMNamesBothMonths(上升/下降/持平三分支,断言两个月份名 + 两个条数都在句子里)、TestMonthText。
  • 顺手修预览测试:GrowthMoM 原来写死 12.3,与 monthly 造的数据对不上, 而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。 改成从 monthly[10]/monthly[9] 算。

安全评估(AI 挖洞背景下的一次全量自检)

报告:outputs/security-audit-2026-10-25.md,脚本在 outputs/sec-audit-2026-10-25/。 结论:无 getshell / SQLi / 未授权读数据;但 2 个 P1 直击「匿名」承诺。

P1-1 匿名提问可植入远程图片(去匿名化)

  • markdown.go 的 safeURL 只校验协议,<img src=https://任意域> 能原样输出。
  • 唯一挡住它的是审核「广告_引流类」维度,而该维度只在 strict 档生效 (censor/policy.go:standard 只有 Abuse/Contraband,lax 为空)。
  • 实测:账号切 standard → <img src="https://tb871870.example.org/standard.png"> 直接入库并渲染; 切回 strict 才被拒。→ 修法必须在 markdown.go 层限制 img src 为站内/白名单,不能依赖档位。
  • 同时确认 8 种混淆(实体编码、java\tscript:、协议相对 //、反斜杠)都被审核识别。

P1-2 访问密码可爆破 + 提问密码校验早于验证码

  • UpdatePagePassword/UpdateAskPassword 只有 maxlen:30,无 minlen,可设 1 位。
  • page.go 的 New() 第一件事就比对 AskPassword,验证码要到后面才校验 → 实测「错误提问密码 + deadbeef:999999」返回「提问密码错误」而不是验证码错误 → 猜密码完全不消耗验证码。限额 30/min(ask)、10/min(page)。

其余P2/P3

  • 登录开放重定向:to=/\evil.example → Location: /\evil.example,浏览器按 WHATWG 把 \ 当 / → https://evil.example/。path.Clean 不处理反斜杠。
  • 注册接口枚举邮箱(返回「已注册」),与 forgot-password 的防枚举口径不一致。
  • 无 CSP;X-XSS-Protection: 1 过时;/user 无 Cache-Control: no-store。
  • 限流/自动封禁是进程内存态(多副本叠加、重启清零)。
  • 匿名投票身份 = vote_fp cookie,清 cookie 即换身份,仅 200/min/IP 兜底。
  • 登录 to 兜底是死代码:path.Clean("/"+to) 恒非空 → 登录后永远落 / 而非箱主页。
  • JSON 响应无 Content-Type(实测 text/plain);429 无 Retry-After。

无法从外网确认的一项

trust_proxy 真实值。三种伪造头(XFF / X-Real-IP / EO-Client-IP)各 22 次都不被采信 (好的方向:无法伪造绕限流),但这既可能是 trust_proxy=false(→ 按 CF 边缘 IP 聚合, 限流与封禁会误伤整片区域),也可能是 true 且在 CF 侧删了 EO-Client-IP。需登服务器看 app.ini。

已验证没事(别重复怀疑)

CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭;跨箱题目/投票越权全 302; 页面密码对主页/整箱分享图/单题分享图/API 四个出口都生效;pixel/service/ShareCard 无 SSRF; exec cwebp 不接用户输入;SMTP 默认校验证书;RegenerateID + pwd_ver;TLS1.0/1.1 拒绝; 无 CORS 头;测试站蜜罐已开(/.git/HEAD、/.env 返回假后台)。

测试卫生

  • 测试站账号 tbunread1022181904,脚本已把严格度恢复 strict、清空 page/ask 密码、 删除本轮 15 条测试提问(115–129)。下次复用前先确认这三项是干净的。
  • 隔离 venv 里装了 requests(~/.workbuddy/binaries/python/envs/default), 比 urllib 顺手;/api/captcha/generate 限流 20/min,批量提交脚本必须带 429 重试。

安全评估(续):trust_proxy=true 引出的 P1-3

用户确认两套环境 trust_proxy = true。据此重做了 IP 采信那一块,发现比原来的推断严重:

P1-3 双 CDN 下权威头优先次序反了

  • internal/shared/ip.go 的 ResolveClientIP 无条件先读 CF-Connecting-IP,再读 EO-Client-IP。
  • 前提「权威头只能由 CDN 写入」只对 Cloudflare 成立:
    • CF:强制覆盖 CF-Connecting-IP,客户端伪造直接 403(生产/测试站实测一致)。
    • EdgeOne:不剥离客户端传入的 CF-Connecting-IP,share.shiroko.one 实测带该头返回 200 (响应头 Server: edgeone makers / EO-LOG-UUID)。
  • 代码优先读 CF 那个头 → 走 EO 线路时客户端 IP 完全可控。后果:限流/IP 黑名单全失效, 且 autoban.Record 可被用来栽赃任意 IP(填别人的 IP 连打 10 次敏感路径 → 对方被封)。
  • 今天打不通:box.shiroko.one 境内递归(AliDNS DoH)只解析到 cloudflare.182682.xyz → 104.18.x/172.64.x, 文档里写的境内 eo.dnse4.com 线路已不在;box.tama.guru 301 跳新域名也走 CF。差一条 DNS 记录。
  • 修法:权威头也套 isTrustedProxy(remote)(回环可用 cloudflared 本机场景),fail-closed; EO 侧删 CF-Connecting-IP;日志同时记 remote_addr 与解析后 IP。

源站定位(外网已尽力,未找到)

  • box.shiroko.one CNAME cloudflare.182682.xyz(第三方 CF SaaS 优选,zone 不在我们手里, 其 Transform/WAF 规则无法自查)。
  • certspotter 枚举 *.shiroko.one 11 个、*.337845818.best 19 个子域,逐个解析全部落 Cloudflare / 腾讯 EdgeOne / Vercel(216.198.79.x、64.29.17.x),无一条直连记录。
  • status.shiroko.one、share.shiroko.one → 43.174.246.43/247.43,用 box 的 SNI 访问回 418 Teapot → 是 EO 边缘不是源站。
  • 结论:源站隐藏目前成功,但「找不到」≠「不可达」,仍需服务器侧 security-selfcheck.sh local 确认 listen = 127.0.0.1:5877。

顺带修正

上一版报告曾把「三种伪造头都不生效」推断为 trust_proxy 可能为 false —— 那是错的解读: 无论 true/false,限流键都落在我自己的真实 IP 上,外部不可分辨。已在报告第四节改正。