安全: 修复凭据、遥测与账号数据清理风险

This commit is contained in:
tamakyi
2026-11-04 14:38:19 +08:00
parent c633789a92
commit 3e7719f200
19 changed files with 305 additions and 3378 deletions
-62
View File
@@ -1,62 +0,0 @@
# 构建与测试(长版明细)
## 构建与测试
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
- ⚠️ **验证 Go 二进制里有没有某个中文字符串,不能用 `strings`**:GNU strings 默认只输出
可打印 ASCII(0x20–0x7E),UTF-8 中文(0xE4+ 开头)整段被丢弃,`strings x | grep 中文`
恒为 0 —— 曾据此误判「用户没重新编译」,实际二进制是新的。
正确做法:`grep -ac "中文字符串" 二进制文件`(实测同一个二进制:strings=0、grep -a=1)。
- ⚠️ 替换二进制后**必须重启进程**:`go build -o X` 只改磁盘文件,正在跑的进程仍是旧映像。
若服务由 systemd/supervisor 管理,再手动 `./X web` 会因端口被占直接退出。
- ⚠️ **分享卡字体不在仓库里**:`static/fonts/` 从未入库也没 embed(`static/fs.go` 的清单里没有),
`/static/fonts/...` 走 HTTP 是 302 回首页。新环境不手工放字体 → `share-card` 恒 500
「字体文件未配置或无法加载」。老环境(如生产)能出图只是因为当年手工补过文件。
排查口诀:响应体是这句中文 → 一定是部署侧缺文件,不是代码回退。
`getFont` 现已支持 candidates(`font_path` → `static/fonts/*` → 系统 CJK 字体)且不缓存失败,
补文件后无需重启。
- ⚠️ **`GOSUMDB=off` 会让 toolchain 自动下载失败**:go.mod 要 1.27.3 而 PATH 上的 `go` 是旧版时,
会报 `golang.org/toolchain@...: verifying module: checksum database disabled by GOSUMDB=off`
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
解法:直接用缓存里的 toolchain 并禁用自动切换——
`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`),不影响用例。
-54
View File
@@ -1,54 +0,0 @@
# 其它约定(长版明细)
## 其它约定
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
## 测试环境 box-dev-api.337845818.best
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
## CLI(`TamaBox <cmd>`)
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
否则 `No help topic for '<cmd>'`。
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
`reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`;`hide-public [--only-reported] [--since N]`。
- 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
- 蜜罐 CLI:`TamaBox honeypot hits|stats|prune`(`--since 24h` / `--show-password` / `--yes`),
注册须在 `cmd/tamabox.go` + 根 `tamabox.go` 两处同步。
- 蜜罐配置:`[security] enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`(默认全关)。
## 行尾与测试坑(2026-10-21 蜜罐轮)
- ⚠️ `internal/route/route.go`、`internal/conf/static.go`、`conf/app.sample.ini` **实际是全 CRLF**;
`grep | cat -A` 不显示 `^M` 极易误判 → Python 替换用 `\r\n` 锚点,写后统计 `b'\r\n'` 复核。
- ⚠️ Go 里 `time.Duration(60)` = 60 **纳秒**,测试写裸数字会让封禁/冷却瞬间过期 → 一律 `60 * time.Minute`。
- ⚠️ 测 `IsBanned` → 403 必须注册中间件(`f.Use(autobanMiddleware(ab, hp))`),只注册路由+NotFound 会被绕过。
## 配置(ini)相关约定
- ⚠️ **ini 对未知键静默忽略** → 键名写错 = 配置不生效且无任何现场线索。
已加两道防线(`internal/conf/keynorm.go` + `unknown.go`):
键名归一化(自动纠正常见误拼)+ 未知键启动告警(附最接近的合法键名)。
- ⚠️ **键名必须与代码逐字一致**,包括警告文案里提到的键名 —— 写错等于误导运维。
- ⚠️ `MapTo` 在**键缺失/值非法时不覆盖**目标字段 → 可安全预置缺省值。
- ⚠️ **bool 的 ini 解析是「有键才赋值」** → 「未配置」与「显式 false」无法用零值区分,
必须用 `HasKey` 判定。缺省为 true 的开关尤其要小心(否则未配置 = 关闭)。
- 归一化必须在 `MapTo` **之前**执行,否则错拼的键已被丢弃。
- 合法键名白名单由**反射从结构体 ini tag 得出**,是单一事实来源;新增配置项自动纳入。
- `TestSampleIniHasNoUnknownKeys` 回归检测:官方样例配置零误报。
改结构体字段名时必须同步 `conf/app.sample.ini`,否则这个测试会失败。
- app.sample.ini 里不要留「代码中从未读取」的死配置(`title` / `xsrf_expire` 已清理)。
-135
View File
@@ -1,135 +0,0 @@
# 安全评估与客户端 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>`),缺口只在跳转参数。