feat(安全): 对外暴露面加固 + 界面去 AI 化(v1.5.0)

界面(去 AI 味):
- 大屏页清除 114 处生成器残留属性 data-page-node-id
- 视觉系统改回工程控制台风格:去 radial/linear-gradient、去辉光、
  去标题前彩色装饰条,改为中性灰阶 + 单一蓝色强调色;KPI 色条改状态点
- 精简各页说教式长提示;修掉 profile.html 泄漏到页面上的 Markdown 星号
- 删除登录页过时的「默认账号 admin / admin123」提示(1.4.0 起已无默认口令)

安全与隐私(按「将会被公网访问」收口):
- 内部异常只回 8 位事件号,完整堆栈进服务端日志(web/api.py::_internal)
- 导出文件名收敛:防响应头注入与路径穿越;manage.py passwd 补用户名校验
- 登录对不存在的账号也走一次哑哈希,抹平用户名枚举的时序差异
- /api/* 读接口限速 240 次 / 60 秒 / 账号(挡住循环调 /api/bundle)
- 进程 umask 0077 + 目录 0700 / 文件 0600:对话正文与主密钥的落盘权限
- 表名与库文件路径只对管理员下发;大屏页所有数据插值转义
- --debug 只允许绑定回环地址;新增 Permissions-Policy 与 413 处理器

文档:
- DEPLOYMENT 新增第十三节「安全与隐私基线」;迁移表补 1.4.0 → 1.5.0 行
- SECURITY 更新支持范围、新增「信息泄漏收敛」小节与上线检查项
- .codebuddy/ 加入 .gitignore(助手工作记忆不进仓库)

版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4)
验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题
这个提交包含在:
2026-09-18 11:13:17 +08:00
父节点 3751dffef9
当前提交 f36149efc3
共修改 29 个文件,包含 1105 行新增和 558 行删除
+109 -7
查看文件
@@ -18,6 +18,7 @@
- [十、日常巡检](#十日常巡检)
- [十一、排错](#十一排错)
- [十二、配置项速查](#十二配置项速查)
- [十三、安全与隐私基线](#十三安全与隐私基线)
---
@@ -437,7 +438,7 @@ name: workbuddy-portal
services:
portal:
image: git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
image: git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
container_name: workbuddy-portal
restart: unless-stopped
init: true # tini 接管 PID 1,docker stop 能干净传到 python
@@ -675,7 +676,7 @@ WB_TRUST_PROXY=1
```bash
export GITEA_TOKEN=<你的令牌>
tools/push-all.sh # 推 main 分支 + 镜像 latest
tools/push-all.sh 1.4.0 # 同时打一个版本 tag 并推送
tools/push-all.sh 1.5.0 # 同时打一个版本 tag 并推送
```
脚本做的事:
@@ -713,15 +714,15 @@ docker login git.iwali.top -u wangchuanli
docker compose build
docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \
git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
```
### 7.4 验证远端
```bash
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
# 或
curl -s -u wangchuanli:TOKEN \
https://git.iwali.top/api/v1/packages/wangchuanli?type=container
@@ -944,8 +945,9 @@ sudo systemctl restart workbuddy-portal
| **1.1.0 → 1.2.0**(单用户 → 多用户) | 0 → 2 | 建 `users` 表并写入首个管理员;`settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键,**老数据整体归到第一个账号**;明文 Cookie **就地加密**;建 `captchas` 表 | 不需要 |
| **1.2.0 → 1.3.0**(权限收敛) | 2 → 3 | **配置作用域收敛**:把管理员个人名下的调度与采集参数提升到实例级 `user_id=0`,再清掉个人残留;实例级不再保留 `cookie` / `user_agent` | 不需要 |
| **1.3.0 → 1.4.0**(备份 + 对外加固) | 3 → 4 | 只做两件事:`users` 加一列 `session_ver`(非空、默认 0)、建 `backups` 表(备份索引)。**没有数据搬运、没有键位变动** | 不需要 |
| **1.4.0 → 1.5.0**(界面改版 + 公网加固) | 4 → 4 | **没有任何库结构变更**,`init_db()` 只做幂等校验。改动都在代码与界面上 | 不需要 |
三次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计:
四次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计:
```bash
docker compose logs portal | grep -iE "迁移|migrat|提升|promote"
@@ -1285,7 +1287,7 @@ docker compose exec portal python manage.py users
| 命令 | 作用 |
|---|---|
| `init [--user U] [--password P]` | 初始化 / 迁移数据库,创建首个管理员(幂等) |
| `serve [--host H] [--port P] [--debug] [--no-scheduler]` | 启动 Web(含进程内调度) |
| `serve [--host H] [--port P] [--debug] [--no-scheduler]` | 启动 Web(含进程内调度)。`--debug` **只允许绑回环地址**(Werkzeug 调试器可执行任意代码) |
| `collect [-u U]` | 执行一次增量采集(不传 `-u` 则逐个启用账号) |
| `migrate-csv [PATH] [-u U]` | 从 v1.0 的 CSV 导入(只读,可重复) |
| `import-xlsx PATH [-u U]` | 从官网导出的 xlsx 导入 |
@@ -1302,3 +1304,103 @@ docker compose exec portal python manage.py users
| `restore <文件名> [--yes] [--no-instance]` | **从备份恢复**(整表替换)。**不加 `--yes` 只打印将要发生什么**;`--no-instance` = 不覆盖 `instance.json` |
Docker 部署下前面加 `docker compose exec portal`。
---
## 十三、安全与隐私基线
> 面向**要把这个实例挂到公网 / 开放注册**的部署者。
> 代码层已经替你关掉的那部分不需要配置;需要**你决定**的部分在 13.2 与 13.4 节。
### 13.1 代码层已经做到的(不用配置,改坏了反而有风险)
| 威胁 | 处理方式 | 入口 |
|---|---|---|
| 撞库 / 爆破 | 图形验证码 + **按来源 IP 硬锁** + 用户名软退避 + 单 IP 尝试总量(40 次 / 5 分钟) | `security.py` |
| 用户名枚举 | 口令校验对不存在的账号也走一次**哑哈希**,两条路径耗时对齐;登录失败文案统一 | `security.login_ok` |
| 会话固定 / 劫持 | 登录时重建会话(顺带换掉 CSRF 与验证码 id);`HttpOnly` + `SameSite=Lax`;改密 / 重置 / 停用后 `session_ver` 立即作废旧会话 | `security.login_session` |
| CSRF | 所有非 GET 请求在 `before_request` 里统一校验;退出登录也改成 POST | `security.check_csrf` |
| XSS | Jinja 自动转义 + 前端手动 `esc()`(含 ECharts 的 HTML tooltip)+ CSP + `nosniff` | `security.CSP`、`dashboard/index.html` |
| 点击劫持 | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` | `apply_security_headers` |
| 开放重定向 | `next=` 只允许站内相对路径 | `security.safe_next` |
| **响应头注入** | 导出文件名一律先收敛成 ASCII 安全名,再按 RFC 5987 附上原名 | `security.content_disposition` |
| **路径穿越** | 备份文件名走 `safe_name()`;导出 CSV 的账号名同样收敛(`manage.py passwd` 建号也补了用户名校验) | `backup.safe_name`、`collect.export_csv` |
| **内部信息泄漏** | 兜底异常只回一个事件号(完整堆栈进服务端日志);普通账号拿不到表名与库文件路径 | `web/api.py::_internal`、`query.manifest` |
| 资源耗尽 | 采集三道闸门(互斥 / 最小间隔 / 跨度上限)+ 重操作最小间隔 + **读接口限速(240 次 / 分钟 / 账号)** + 容器 `cpus`/`mem_limit`/`pids_limit` | `web/api.py`、`security.api_rate_ok`、`docker-compose.yml` |
| 凭证泄漏 | Cookie 静态加密入库;页面与接口只回「长度 + 尾 4 位」;导出与备份都不含明文 | `crypto.py`、`db.get_secret` |
| **落盘权限** | 进程 `umask 0077` + 目录 0700 / 文件 0600(SQLite 的 `-wal`/`-shm`、导出 CSV、备份 zip 一并覆盖) | `config.harden_process` |
| 调试器 RCE | `--debug` 只允许绑定回环地址,绑对外地址直接拒绝启动 | `manage.py cmd_serve` |
### 13.2 公网暴露前你必须自己做的
1. **上 HTTPS,并把开关一起打开**(缺一个就是半截配置):
```
WB_FORCE_HTTPS=1
WB_COOKIE_SECURE=1
# 反向代理终止 TLS 时还要:WB_TRUST_PROXY=1(且代理必须重写 XFF)
```
见 [第六节](#六反向代理与-https)。
2. **直连公网时 `WB_TRUST_PROXY` 必须留 0**。置 1 的前提是「**你自己的**反代会重写
`X-Forwarded-For`」;否则攻击者每换一个伪造的 XFF,验证码限速、注册配额、
登录锁定三道 IP 防线会同时失效。
3. **显式指定管理员口令**:`WB_ADMIN_PASSWORD=<强口令>`。留空时程序会生成随机口令并
只在启动日志里打印一次,忘了抄就只能 `manage.py passwd` 重置。
4. **决定要不要开放注册**:`allow_register`(默认 `1`)。开放就确认
`captcha_policy=always` 与 `register_max_per_ip` 的取值。
5. **别把备份卷和 `data/` 卷暴露出去**:备份 zip 里含**主密钥**(`instance.json`),
拿到它等于拿到全库凭证的明文。备份目录默认不在 `data/` 内,容器里挂的是独立卷。
6. **在应用前面再加一层限制**(可选但强烈建议):反代 / 云安全组做 IP 白名单或接 WAF。
应用层的限速是最后一道,不该是唯一一道。
### 13.3 隐私:本系统存了哪些个人数据
| 数据 | 存在哪 | 谁能看到 | 备注 |
|---|---|---|---|
| **对话正文**(`User Prompt`) | `usage_records.prompt`;管理员导出时落到 `data/exports/*.csv` | 仅本人(页面 / 导出);管理员在**备份归档**里也能拿到 | 本系统里最敏感的一类数据。入库按 `max_prompt` 截断(默认 2048 字符) |
| 云端凭证(Cookie / UA) | `settings.value`,**密文** | 仅本人,且只能看到「长度 + 尾 4 位」 | 主密钥在 `data/instance.json`(0600) |
| 账号资料 | `users`(用户名 / 显示名 / 邮箱) | 本人 + 管理员 | 邮箱选填 |
| 来源 IP | `users.register_ip`、`users.last_login_ip`、`audit_log.ip` | 仅管理员 | 用于每日注册配额与事后追责 |
| 采集运行日志 | `collect_runs.detail`、`logs/app.log` | 本人(自己的);`/logs` 整页仅管理员 | 访问日志只记路径,**不记 query string** —— 否则 `?q=<搜索词>` 会把 prompt 片段带进日志文件 |
三条数据边界由**服务端**强制,不靠界面隐藏:
- 普通账号的一切读写都带 `user_id = 当前账号`;越权写入直接 `400`,并记一条
`settings_rejected` 审计;
- 管理员在「用户管理」看得到账号列表与登录 IP,但**看不到任何人的用量与凭证**;
- 凭证明文只在「真正要拿它对外发请求」的那一刻解出来,不进日志、不进响应体。
### 13.4 保留期与删除:需要你自己定的部分
**代码刻意不做自动清理** —— 自动删数据比留数据危险得多。所以下面这些是部署者的事:
- `prompt` 与 `audit_log` 会一直留着。需要按时间裁剪时(**先 `manage.py backup`**):
```sql
-- 例:清掉 180 天前的对话正文(保留行与积分,只去正文)
UPDATE usage_records SET prompt='' WHERE day < date('now','-180 day');
-- 例:清掉 365 天前的审计
DELETE FROM audit_log WHERE at < date('now','-365 day');
```
改完用 `manage.py vacuum` 回收空间。
- **删除账号已经是「被遗忘权」的落地方式**:「用户管理 → 删除」默认**连同该账号的用量
数据与 Cookie 一起删除**;用户自己可以走「个人中心 → 导出我的全部数据」把数据带走。
- **导出文件的留存**:`data/exports/*.csv` 是对话正文的明文副本,没有谁会自动清。
容器里它在 `wb_data` 卷内 —— 保留多久由你决定,别让它一直躺在磁盘上。
### 13.5 上线前的自检
```bash
# 1) 应用层回归(第 9 节专门验「对外暴露面」:响应头收敛 / 异常不外泄 / 读接口限速)
python tools/smoke.py
# 2) 从外部看到的响应头
# 期望看到 CSP / Permissions-Policy / X-Frame-Options / nosniff / Referrer-Policy
curl -sI https://你的域名/login | grep -iE 'content-security|permissions-policy|x-frame|nosniff|referrer'
# 3) 确认没有把调试器挂出去(这条应当直接报错退出,而不是启动成功)
python manage.py serve --debug
```