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 部署与验收的具体跑法
This commit is contained in:
@@ -47,3 +47,80 @@
|
|||||||
- 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上,
|
- 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上,
|
||||||
而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。
|
而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。
|
||||||
改成从 `monthly[10]/monthly[9]` 算。
|
改成从 `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 上,外部不可分辨。已在报告第四节改正。
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 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`)。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 2026-10-27
|
||||||
|
|
||||||
|
## 在 WSL Debian 上把 TamaBox 跑起来,做 P1 修复的端到端复验
|
||||||
|
|
||||||
|
用户新搭了 WSL2 Debian,给了 `data/data.txt`(测试环境的 PG / Redis / OSS 凭据),要求「用它部署测试一下」。
|
||||||
|
|
||||||
|
**环境**(详细踩坑见 `topics/build.md` 的「本地部署」小节):
|
||||||
|
- `wsl.exe -d Debian -u root` 可免密进 root;apt 装了 `redis-server / python3 / python3-requests /
|
||||||
|
gcc / libwebp-dev`(有 libwebp 后 `s3.go` 正常编译,不用 s3stub overlay)。
|
||||||
|
- Go:`/opt/go` 装 1.27.3,但 `~/.bashrc` 里 `GOROOT=/usr/local/go`(1.26.3)→ 必须 `env -u GOROOT`。
|
||||||
|
- PG:新建 role `tamabox`(CREATEDB)+ 库 `tamabox`、`nekobox`(schema `nekobox`)。
|
||||||
|
- 服务:`/etc/systemd/system/tamabox-test.service`,源码在 `~/tb/src`,二进制 `~/tb/tamabox`,
|
||||||
|
日志 `~/tb/server.log`,端口 5877。配置 `~/tb/src/conf/app.ini`(`production=true`、
|
||||||
|
`external_url=http://127.0.0.1:5877`、`captcha_type=math`、`enable_text_censor=false`、`trust_proxy=true`)。
|
||||||
|
|
||||||
|
**验收结果(全 PASS)**:
|
||||||
|
- P1-1 外链图片剥离 / 白名单图片保留 / 实体与反斜杠变体;
|
||||||
|
- P1-2a 4 位密码被拒、6 位生效;P1-2b 验证码先于密码;P1-2c 6 次失败后正确密码仍被锁;
|
||||||
|
- P1-3 A/B:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 命中黑名单 302;Windows 侧(非回环)同样头 → 忽略并 WARN;
|
||||||
|
- 回归:18 条 XSS 载荷无可执行/无外联、CSRF 无 token 400、越权全 302;
|
||||||
|
- 单测:`internal/{template,shared,form,conf,db,route}` + `route/{question,vote}` 全绿。
|
||||||
|
|
||||||
|
**这次揪出的新问题(已修)**:P1-1 的白名单把配置串整串当主机名,而真实配置是
|
||||||
|
`image_bucket_cdn_host = "r2.337845818.best/tamabox"`(host/path)→ 自家 R2 图片被一起剥掉。
|
||||||
|
修法:`normalizeHostLike()` 归一化(补协议 → url.Parse → Hostname),endpoint 主机自身也入白名单
|
||||||
|
(R2 path-style 链接是 `<endpoint>/<bucket>/…`);新增 `TestSafeImageSrc_RealConfiguredHosts`,
|
||||||
|
变异验证(退回旧逻辑 → 4 条放行用例变红)。**教训:白名单类单测必须照抄生产配置的写法,不能自己编一个好写的形态。**
|
||||||
|
|
||||||
|
**P2-3 从推断升级为实测**:`/login?to=/\evil.example` 登录后 `Location: /\evil.example`,
|
||||||
|
按 WHATWG 解析 = `http://evil.example/`;`\\evil.example` 同样外跳(`//`、`///` 已被规整)。
|
||||||
|
同一载荷在正文渲染里是安全的(`<a href="/\example.org">` 被剥成 `<a>`)。修复未做,等用户指示。
|
||||||
|
|
||||||
|
**产物**:
|
||||||
|
- 报告新增「六、本地部署实测」章节(`outputs/security-audit-2026-10-25.md`);
|
||||||
|
- 脚本新增 `verify_p1_allow.py`(正向放行)、`local_seed.py`(本地注册审计账号),
|
||||||
|
`audit.py` 改为 `TB_BASE/TB_EMAIL/TB_PWD/TB_DOMAIN` 可覆盖 + 验证码 429 退避。
|
||||||
|
|
||||||
|
**待办**:改动仍未 commit / push;P2-3、P2-4、P2-5、P2-6、P2-7 未动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二轮:修完 P2/P3 + 全局复查
|
||||||
|
|
||||||
|
用户:「把你发现的问题修了,然后全局检查一遍有无其他问题」。
|
||||||
|
|
||||||
|
**已修(P2-3~P2-8、P3-9~P3-15)**:都写进 `topics/sec.md` 的「第二轮修复」。要点:
|
||||||
|
跳转白名单收敛到 `shared.SafeRedirectTarget`(`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`);
|
||||||
|
注册三条失败分支共用 `registerSucceeded`;CSP 三档 + umami 的 `mami.shiroko.one` 必须入 `script-src`;
|
||||||
|
`no-store`;`securityHeaders` 前移;封禁落库 `ban_records`;常量时间比较;页码上限;429 统一出口。
|
||||||
|
|
||||||
|
**全局复查新发现 3 个**(详见 `topics/sec.md`):
|
||||||
|
1. 🔴 `chat_handler.SendMessage` 的 POST 缺 `conv.OwnerID != pageUser.ID` —— 被拉黑者换 domain 即可继续骚扰
|
||||||
|
+ 触发邮件 + 绕过屏蔽词/日限额。已修,3/3 PASS,并做变异验证(去掉校验后对应用例变红)。
|
||||||
|
2. 🟡 页面密码 `next` 的反斜杠外跳(与 P2-3 同源)。
|
||||||
|
3. 🟡 分享卡无字体时每请求烧数秒 CPU(公开端点)→ 失败结果缓存 1 分钟,实测 3.44s → 0.53s。
|
||||||
|
|
||||||
|
**验收**:`verify_p1_fixes.py` 4/4、`verify_round2.py` 19/19、`verify_page_next.py` 6/6、
|
||||||
|
`verify_chat_owner.py` 3/3;单测全绿;全项目编译通过。测试数据已清(库里只剩 `tb-local@example.com`)。
|
||||||
|
|
||||||
|
**报告**:`outputs/security-audit-2026-10-25.md` 新增「七、第二轮修复与全局复查」。
|
||||||
|
|
||||||
|
**再待办**:仍未 commit / push(26 改 + 9 新);CSP 是否切 `enforce`;生产机补 glyf 轮廓字体。
|
||||||
|
|
||||||
|
**收尾复跑发现两个「假红」**(已写进 `topics/sec.md`):① 不给 `TB_BASE` 时脚本打的是远端
|
||||||
|
box-dev-api,没部署修复所以 4/4 FAIL;② 页面密码入口限流 10 次/分钟 + p1-2c 的 10 分钟锁定
|
||||||
|
会让 `verify_page_next.py` 后两项 429。顺手把该脚本判据从「Location 不含 evil」改成精确比对
|
||||||
|
`/_/<domain>`、每条载荷开新会话、429 单独报错。四套最终:4/4、19/19、6/6、3/3。库已清回只剩 `tb-local`。
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
明细已拆分到 `topics/*.md`(按需 Read 全文):
|
明细已拆分到 `topics/*.md`(按需 Read 全文):
|
||||||
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
|
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
|
||||||
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
|
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
|
||||||
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `misc` 其它约定。
|
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `sec` 安全评估·客户端 IP 信任 / `misc` 其它约定。
|
||||||
|
|
||||||
## 协作
|
## 协作
|
||||||
|
|
||||||
@@ -18,7 +18,10 @@
|
|||||||
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
|
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
|
||||||
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
|
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
|
||||||
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。
|
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。
|
||||||
- 本沙箱起不了 `TamaBox web`(session 固定 redis,6379 未开)→ HTTP 端到端只能部署后做。
|
- ~~本沙箱起不了 `TamaBox web`~~ **2026-10-27 起可在 WSL2 Debian 上真跑**(PG+Redis+systemd,端口 5877):
|
||||||
|
详见 topics/build.md「本地部署」小节。要点:`env -u GOROOT`(bashrc 指着 1.26.3)、
|
||||||
|
`[recaptcha] captcha_type = math`、Windows 侧 127.0.0.1:5877 被占要用 WSL eth0 IP。
|
||||||
|
HTTP 端到端与 P1-3 的双路径 A/B 都能在这里做(回环=可信、跨主机=不可信)。
|
||||||
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
|
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
|
||||||
|
|
||||||
## 行尾 / 模板 / embed(topics/embed.md)
|
## 行尾 / 模板 / embed(topics/embed.md)
|
||||||
@@ -59,6 +62,19 @@
|
|||||||
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
|
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
|
||||||
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。
|
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。
|
||||||
|
|
||||||
|
## 安全(topics/sec.md)
|
||||||
|
|
||||||
|
- 权威头 `CF-Connecting-IP` / `EO-Client-IP` 只在 TCP 对端可信(回环 或 `trusted_proxies` 命中)
|
||||||
|
才采信,公网对端一律忽略;唯一读取点 `internal/shared/ip.go`,别在别处另读 header。
|
||||||
|
- 生产回源 = cloudflared Tunnel 回本机(对端 127.0.0.1)→ 零配置;`trust_proxy` 两套环境都 true。
|
||||||
|
- **EdgeOne 已取消**(境内速度差)。重新启用前必须把 EO 回源网段写进 `trusted_proxies`,
|
||||||
|
否则 EO 线退化成按边缘 IP 聚合(限流互挤、自动封禁误伤整片)。`app.sample.ini` 已注明。
|
||||||
|
- 跳转参数只走 `shared.SafeRedirectTarget`;`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`。
|
||||||
|
- **同一 handler 里 GET 有所有权校验 ≠ POST 有**(私聊 `SendMessage` 曾漏,可跨箱骚扰)。逐分支核对。
|
||||||
|
- CSP 三档默认 `report`;`script-src` 必须含 `mami.shiroko.one`(umami 是动态注入,grep 抓不到)。
|
||||||
|
- 注册防枚举:邮箱重复/停用与正常注册共用同一个成功出口;代价是忘密只能走找回。
|
||||||
|
- 报告 `outputs/security-audit-2026-10-25.md` + 复现脚本 `outputs/sec-audit-2026-10-25/`。
|
||||||
|
|
||||||
## 其它约定(topics/misc.md)
|
## 其它约定(topics/misc.md)
|
||||||
|
|
||||||
- 校验用 `valid:"required;maxlen:N"` 标签。
|
- 校验用 `valid:"required;maxlen:N"` 标签。
|
||||||
|
|||||||
@@ -32,3 +32,31 @@
|
|||||||
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
|
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
|
||||||
解法:直接用缓存里的 toolchain 并禁用自动切换——
|
解法:直接用缓存里的 toolchain 并禁用自动切换——
|
||||||
`PATH="$HOME/go/pkg/mod/golang.org/[email protected]/bin:$PATH" GOTOOLCHAIN=local go test ./...`
|
`PATH="$HOME/go/pkg/mod/golang.org/[email protected]/bin:$PATH" GOTOOLCHAIN=local go test ./...`
|
||||||
|
|
||||||
|
## 本地部署(WSL2 Debian,2026-10-27 打通)
|
||||||
|
|
||||||
|
用户本机有 WSL2 `Debian`(可 `wsl.exe -d Debian -u root` 免密进 root)。在这上面跑通了完整服务,
|
||||||
|
从此 HTTP 端到端不必「部署到测试站才能做」。要点:
|
||||||
|
|
||||||
|
- **GOROOT 是个坑**:`~/.bashrc` 里 `export GOROOT=/usr/local/go`(1.26.3)。用 `/opt/go/bin/go`
|
||||||
|
(1.27.3,从 dl.google.com 下的 tar 解到 /opt)时必须 `env -u GOROOT`,否则
|
||||||
|
`compile: version "go1.26.3" does not match go tool version "go1.27.3"`。
|
||||||
|
另:GOCACHE 被旧版本编译过也会报同样的错 → 换独立 `GOCACHE=~/.cache/go-build-tb`。
|
||||||
|
- **依赖**:`apt-get install redis-server python3 python3-requests gcc libwebp-dev pkg-config`
|
||||||
|
(有 libwebp 后 `internal/storage/s3.go` 正常编译,不再需要 s3stub overlay)。
|
||||||
|
session 与 cache 都硬依赖 redis,6379 不通就起不来;`systemctl start redis-server` 即可(WSL 有 systemd)。
|
||||||
|
- **库**:DSN 不带库名 → 连的是与用户名同名的库。测试还要 `CREATEDB` 权限(`NewTestDB` 自建临时库)。
|
||||||
|
`CREATE ROLE tamabox LOGIN PASSWORD '...' CREATEDB; CREATE DATABASE tamabox OWNER tamabox;
|
||||||
|
CREATE DATABASE nekobox OWNER tamabox; CREATE SCHEMA nekobox AUTHORIZATION tamabox;`
|
||||||
|
- **配置坑**:`data/data.txt` 里的 `[app] title`、`[server] xsrf_expire`、`[captcha] provider`、
|
||||||
|
`[rate_limit] *` 全是当前版本不认识的键(启动 WARN 后忽略)。验证码要关外网校验得写
|
||||||
|
`[recaptcha] captcha_type = math`。`external_url` 用 http → session cookie 不带 Secure,本地能登录。
|
||||||
|
- **进程托管**:`/etc/systemd/system/tamabox-test.service`(User=tamakyi、WorkingDirectory=源码目录、
|
||||||
|
ExecStart=<二进制> web、日志 append 到文件)。`systemctl restart` 必须 root,普通用户报 Access denied。
|
||||||
|
- **访问路径**:Windows 侧 `http://127.0.0.1:5877` 被本机别的进程占用(返回 502),要用
|
||||||
|
`http://<wsl eth0 ip>:5877`。这个差异正好白送一套 P1-3 的 A/B:
|
||||||
|
WSL 内 `curl 127.0.0.1` = 回环对端(模拟 cloudflared 回源),Windows 侧访问 = 非回环对端(模拟公网直连)。
|
||||||
|
- **抓 IP 伪造**:临时配 `ip_blacklist = 1.2.3.4` + `ip_blacklist_redirect_url`,带头 curl 看有没有 302,
|
||||||
|
比读日志直观;配合日志里的 `remote_addr` / `header_ip` 双重取证。
|
||||||
|
- **限流会绊脚本**:`/api/captcha/generate` 按 IP 限流,批量跑用例会 429,
|
||||||
|
`audit.get_captcha` 已加退避重试。SMTP 密码不对只会打日志(`526 Authentication failure`),不影响用例。
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 安全评估与客户端 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>`),缺口只在跳转参数。
|
||||||
Reference in New Issue
Block a user