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 行删除
+110
查看文件
@@ -8,6 +8,116 @@
---
## [1.5.0] — 2026-09-18
**主题:按「将会被公网访问」重新审一遍界面与暴露面**
两件事:① 清掉界面里的 AI 生成残留与「AI 味」,把视觉从「霓虹渐变」改回工程控制台;
② 按「能被公网访问」的前提逐条收口信息泄漏、注入与资源滥用。
**没有库结构变更**(`user_version` 仍是 4),升级不需要手工介入。
### 安全与隐私加固(对外提供服务)
**主题:把「能被公网访问」当成前提,逐条收口信息泄漏与资源滥用**
这一轮没有新增功能,全部是收口。原则:**能把信息少给一点就少给一点,
能把权限多收一层就多收一层**。
**信息泄漏(这类问题的共同点是:平时看不出问题,踩点阶段最好用)**
- **内部异常不再直出**。`/api/collect`、`/api/maintenance/*`、`/api/backups*` 的兜底
`except Exception` 原本把 `str(e)` 原样回给客户端 —— 那里面会带绝对路径
(`/app/workbuddy_portal/...`)、SQL 语句、`sqlite3` 的报错。现在统一走
`web/api.py::_internal`:完整堆栈写进服务端日志并打一个 **8 位事件号**,
客户端只拿到事件号。既不影响「出事了去查日志」,也不送材料。
(`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError` 这些**面向用户写的**
业务异常不受影响,它们的文案本来就是给用户看的。)
- **普通账号拿不到内部实现清单**。`/api/manifest`、`/api/bundle` 里的 `sources`
(表名 `usage_records`、`daily 聚合视图` …)与 `archive`(`data/usage.sqlite`)
只对管理员下发;非管理员的大屏页「数据源」卡片自动收起,避免留一个只有表头的空壳。
- **响应头注入**。导出文件名里含用户名,而用户名**并不总是**注册正则的产物
(`manage.py passwd` 建号时不校验、老库升级上来的名字也可能带引号或 CR/LF)。
一旦名字里有引号或换行,直接拼 `Content-Disposition` 就是响应拆分。
新增 `security.safe_filename()` 与 `security.content_disposition()`:
收敛成 ASCII 安全名 + 按 RFC 5987 附上原名,`/records/export` 与
`/profile/export` 都改走它。
- **路径穿越**。`collect.export_csv()` 的账号名 tag 直接拼进文件路径 ——
一个叫 `..\..\x` 的账号能把 CSV 写到数据目录之外。现在只保留 `[A-Za-z0-9._-]`,
且结果固定落在 `EXPORT_DIR` 之下。同时 `manage.py passwd` **建号时补上用户名校验**
(与注册页同一套),从根上不再产生这类名字。
- **用户名枚举的时序侧信道**。`login_ok` 在账号不存在时不执行任何哈希计算,
比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在。
现在对不存在的账号也走一次**同代价的哑哈希**,两条路径耗时对齐。
**隐私:落盘权限(这条是对外部署最容易被忽略的)**
- 数据目录里躺着的是:`usage_records.prompt`(**用户与 AI 的完整对话正文**)、
凭证密文、`instance.json`(主密钥)、`exports/*.csv`(对话正文的**明文**副本)、
`backups/*.zip`(全库 + 主密钥)。默认 `umask 022` 会把它们留成 `0644 / 0755`,
也就是**同机任何用户都能读**。
- 新增 `config.harden_process()`:在进程启动时把 `umask` 收到 `0077`,
此后新建的一切文件(含 SQLite 的 `-wal` / `-shm`)天然是 owner-only。
刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏,
而漏掉的那个文件里往往正好是最新的对话正文。
- 配套 `harden_dir()` / `harden_file()` 收紧**已经存在**的目录(老版本留下的 0755)
以及导出 CSV、备份 zip 落盘后的权限。Windows 无此模型,函数直接跳过。
**资源滥用**
- **读接口限速**:登录有 IP 锁定、注册有配额、采集有最小间隔,但读接口原本**没有任何刹车** ——
一个注册账号把 F12 里的 `/api/bundle` 请求放进 `for` 循环就能持续吃 CPU 与出口带宽
(那个接口要算全量逐日聚合,还会下发最多 2 万条明细)。现在 `/api/*` 按
「账号(未登录时按来源 IP)」限速 240 次 / 60 秒,超出返回 `429`。
阈值给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到。
**其它**
- **`--debug` 不再能绑到对外地址**。Werkzeug 的交互式调试器等于任意代码执行,
而 `--host` 默认就是 `0.0.0.0`;`python manage.py serve --debug` 会直接把 RCE 挂上公网。
现在绑非回环地址直接拒绝启动,并提示本机调试的正确写法。
- 新增 `Permissions-Policy` 响应头(显式关掉地理位置 / 麦克风 / 摄像头 / 支付 / USB)。
- 新增 `413` 处理器:请求体超限时接口返回 JSON 而不是 Flask 默认 HTML 页
(前端 `r.json()` 在 HTML 上会炸,表现成「Unexpected token <」)。
- 文档:新增 [部署指南第十三节「安全与隐私基线」](DEPLOYMENT.md#十三安全与隐私基线) ——
代码层已做到的、公网暴露前必须自己做的、存了哪些个人数据、保留期怎么定、上线自检命令。
验证:`python tools/smoke.py` **ok=264 / fail=0**(第 9 节专门覆盖上述各项:
`safe_filename` 边界、真实导出响应头不含引号/换行、每个 `except Exception` 都走 `_internal`、
读接口限速、安全响应头、大屏页已转义、普通账号拿不到数据源清单)。
### 界面去 AI 化
**主题:清掉界面里的 AI 生成残留与「AI 味」**
- **大屏页清理生成器残留**:`static/dashboard/index.html` 里 **114 处**
`data-page-node-id="…"`(页面生成工具给每个节点打的标记)全部删除,
文件从 64 KB 降到 58 KB。这类属性没有任何运行时作用,只是生成痕迹。
- **视觉系统重做**(`static/css/app.css` + 大屏内联样式):由「深色底 + 霓虹渐变 + 辉光」
改为中性的工程控制台风格 —— 灰阶打底、单一蓝色强调色。具体删掉了:
`radial-gradient` 页面背景、按钮/激活态/分页/徽标的 `linear-gradient`、
`box-shadow` 发光(品牌点、主按钮、日历格)、渐变文字(错误页大号状态码)、
以及标题前的彩色装饰条(`.pagehead h1::before`、`.card h2::before`、`h1::before`)。
- **KPI 改用灰度分层**:原来每张卡一条彩虹色左色条,现在 `--c` 只渲染成一个 8px
状态点(`采集健康`/`自动备份` 这类状态卡仍能一眼区分),其余靠灰度与字重。
- **图表配色同步降饱和**:ECharts `PALETTE`、日历 5 级色阶、热力图 `visualMap`
与后台共用同一套语义色;数据系列改用蓝色阶 + 1 个琥珀色对比项。
- **文案精简**:删掉说教式长段落(`配置管理` 页整张「为什么采集参数是只读的」说明卡)、
把只给结论即可的提示压成一句话;`任务管理`/`配置管理`/`备份管理`/`用户管理`
的长提示同步收敛。
- **修掉两个真实缺陷**(都在这次清理中暴露):
- `templates/profile.html` 的 Markdown 星号漏进 HTML —— `**任何时候都不回传明文**`
会在页面上原样渲染成星号;
- 登录页写着「首次部署默认账号 `admin` / `admin123`」,而 1.4.0 起已改用随机口令,
该提示会把人引向一个永远登不上的口令,已删除。
- **文档同步**:`docs/USER-GUIDE.md` 中引用上述被删文案的两处说明一并更新。
界面截图 `docs/images/*.png` 仍是旧观感,需要用 `python tools/shots.py` 重出。
验证:`python tools/smoke.py` 全绿(ok=233 / fail=0),其中
「页面 class 与 app.css 选择器对账」确认新版样式表没有漏掉任何模板在用的类名;
`python tools/check_docs.py` 0 处问题。
---
## [1.4.0] — 2026-09-16
**主题:备份管理 · 对外提供服务的安全加固 · 资源与频率限制**
+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
```
+2 -4
查看文件
@@ -413,13 +413,11 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
页面上、接口里都拿不到明文。若显示「**无法解密**」的红字横幅,说明实例主密钥被换过,
重新粘贴一次即可。
> 凭证卡片上会有一行提示写着「**这一块是你唯一可以修改的配置**」——
> 看到它就找对地方了。
> 凭证卡片上会有一行提示写着「普通账号只有这一块可改」——看到它就找对地方了。
### 9.2 采集参数(只读)
普通账号在这一块看到的是**只读表格**,输入框不可编辑,页面上还有一张
「**为什么采集参数是只读的**」说明卡:
普通账号在这一块看到的是**只读表格**,输入框不可编辑:
| 参数 | 默认 | 说明 |
|---|---|---|