feat(权限): 收敛普通账号写权限至本人凭证

将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。
新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0,
消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检,
smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收,
忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
这个提交包含在:
2026-09-18 08:46:00 +08:00
父节点 df7db3582e
当前提交 1bf961f6b3
共修改 42 个文件,包含 2615 行新增和 854 行删除
+33 -7
查看文件
@@ -6,9 +6,15 @@
| 版本 | 是否接受安全修复 |
|---|---|
| `1.2.x`(当前) | ✅ |
| `1.3.x`(当前) | ✅ |
| `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) |
| `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) |
> **1.3.0 修掉了什么**:此前「调度时刻 / 采集参数」是**个人级**配置,普通账号可以
> 自己改(等于让普通账号决定这台服务器怎么发请求、关不关 TLS 校验);
> 且 `/logs` 对普通账号开放。现在这两块都收归管理员,普通账号只保留
> 「维护本人 Cookie / User-Agent」这一项写权限。
## 如何报告漏洞
**请不要在公开 Issue 里贴出可直接利用的细节**(含真实 Cookie、`secret_key` / `cookie_key`、可复现的绕过步骤)。
@@ -37,9 +43,12 @@
| 项 | 做法 | 位置 |
|---|---|---|
| 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` |
| 角色 | 管理员 / 普通两档;`/users`、`/logs/tail`、`vacuum` 等仅管理员 | `security.admin_required` |
| 角色(两档) | 管理员 / 普通。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum` | `security.admin_required` |
| **写权限只有一条规则** | `config.writable_by(key, is_admin)` —— 普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人那份)。页面与接口共用这一个判断,杜绝「界面置灰但接口还能写」 | `config.writable_by` |
| **界面置灰 ≠ 安全边界** | 页面上的 disabled / hidden 只是「不给误导性按钮」;真正的闸门是服务端 `@admin_required` 与 `writable_by()`,所以直接敲 URL 或构造请求也过不去 | `web/views.py`、`web/api.py` |
| 越权写整单拒绝 | 一次提交里只要含不可写的键,整个请求 `400` + `denied` 点名,不做「部分生效」 | `web/api.py:api_settings_post` |
| 停用即失效 | `current_user()` **每个请求**回查 `users.status`,不等 12 小时会话过期 | `security.current_user` |
| 自锁保护 | 管理员不能停用 / 降权 / 删除自己 | `web/api.py` |
| 自锁保护 | 管理员不能停用 / 降权 / 删除自己;也不能删掉最后一个启用的管理员 | `web/api.py` |
| CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` |
| 会话签名 | Flask `secret_key` 由 `data/instance.json` 持有,首次启动随机生成 | `workbuddy_portal/config.py` |
| 会话 cookie | `HttpOnly` + `SameSite=Lax` + `Path=/`;HTTPS 部署可设 `WB_COOKIE_SECURE=1` 打开 Secure | `workbuddy_portal/__init__.py` |
@@ -54,10 +63,14 @@
|---|---|---|
| 强隔离 | `uid` 是 `conn` 之后的**第一个位置参数且无默认值**;漏传直接 `TypeError`,不会退化成「返回全量」 | `query.py` / `collect.py` / `scheduler.py` |
| 按 id 取单条也隔离 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` | `web/api.py` |
| 配置作用域 | 三级回落 `个人 → 实例(user_id=0) → DEFAULTS`;`GLOBAL_KEYS` 只有管理员能改 | `db.get_settings`、`config.GLOBAL_KEYS` |
| **配置作用域与写权限对齐** | 「键存在哪一级」与「谁能改」是一件事:`GLOBAL_KEYS` 一律存**实例级 `user_id=0`** 且仅管理员可写;`USER_EDITABLE_KEYS` 是个人级且人人可写本人那份。`set_setting()` 强制把全局键重定向到 `user_id=0`,从结构上消除「管理员改了只有自己生效」 | `config.GLOBAL_KEYS` / `config.USER_EDITABLE_KEYS`、`db.set_setting` |
| 三级回落 | `个人 → 实例(user_id=0) → DEFAULTS` | `db.get_settings` |
| 凭证不回落 | `NO_FALLBACK_KEYS = {cookie, user_agent}` **不参与实例级回落** —— 回落等于新账号继承管理员凭证,是最严重的串号越权 | `db.get_setting` |
| 日志隔离 | 采集运行记录按账号下发;`/logs/tail`(应用日志文件)仅管理员 | `web/views.py` |
| 实例级也不存凭证 | `init_db()` 灌默认值时跳过 `USER_EDITABLE_KEYS`,并显式 `DELETE FROM settings WHERE user_id=0 AND key IN ('cookie','user_agent')` —— 实例级存凭证等于给所有账号发同一张身份 | `db.init_db` |
| 实例级不是孤儿 | `user_id=0` 不对应任何真实账号,**任何「清理孤儿行」的语句都必须排除它**(历史缺陷:`DELETE ... WHERE user_id NOT IN (SELECT id FROM users)` 曾把实例级配置整片删掉) | `tools/smoke.py` 防回归断言 |
| 日志隔离 | `/logs`、`/logs/tail` **仅管理员**;操作审计对普通账号只下发本人记录 | `web/views.py`、`web/api.py` |
| 导出不互相覆盖 | `/records/export` 与 CLI `export-csv` 的文件名带账号名 | `web/views.py`、`collect.export_csv` |
| 前端不误提交只读项 | `app.js:formData()` 跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)—— disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端按「越权修改只读项」拒掉**整单** | `web/static/js/app.js` |
### 云端凭证(Cookie)的保密
@@ -69,7 +82,7 @@
| 只回掩码 | 页面与 `/api/settings` 只给「N 字符,结尾 …xxxx」与 `broken` 标志,`secret_state()` 不返回明文 | `db.secret_state` |
| 失败即报错 | `decrypt()` 校验失败**抛 `DecryptError`**,绝不「失败就返回原值」;非 `v1.` 前缀视为历史明文原样返回(下次写入自动升级) | `crypto.decrypt` |
| 历史明文清理 | 启动迁移时把 settings 里残留的明文凭证就地加密,并写一条 `encrypt_secrets` 审计 | `db._encrypt_legacy_secrets` |
| TLS 校验 | 默认开启,**不提供「关掉校验」的快捷开关**(Cookie 不该裸奔) | `settings.ssl_verify` |
| TLS 校验 | 默认开启;`ssl_verify` 是**实例级且仅管理员可改** —— 能让普通账号关掉它,等于允许把所有人的 Cookie 发往不校验证书的地址 | `settings.ssl_verify`、`config.GLOBAL_KEYS` |
### 防自动化攻击
@@ -91,16 +104,22 @@
| 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` |
**绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、
`data/shots/`(截图里可能有真实账号信息)、`data/demo/`、`backups/`(库快照 = 凭证密文 + 密码哈希)、
`logs/*`、`.env`。
`.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v <file>` 逐条复核。
注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。
> `backups/` 同时也是一个**刻意不放在 `data/`** 的目录:`data/` 在 Docker 部署下是命名卷,
> `docker compose down -v` 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。
## 已知的**非**目标(部署方需自行处理)
本项目刻意不做下面这些,请按你的环境补齐:
- **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时
**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。
注意:纯 HTTP 下流量在网内是明文的,同一局域网内的中间人可以看到会话 Cookie 与 Prompt 内容。
- **没有 CSRF 之外的重放防护 / 没有 WAF**:公网暴露前请置于反向代理的 rate limit 之后。
- **没有备份机制**:备份策略需要你自己定(见 `docs/DEPLOYMENT.md`)。
- **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。
@@ -108,6 +127,9 @@
- **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。
它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。
- **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。
- **管理员在运维层面是可信角色**:能登录部署机器的人可以看到数据库文件、应用日志,
理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 ——
界面层的隔离保护的是**使用者之间**,不是「使用者 vs 服务器管理员」。
## 部署前的最小检查清单
@@ -116,6 +138,10 @@
- [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写(内含 `instance.json` 的两个密钥)
- [ ] 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 `WB_COOKIE_SECURE=1`
- [ ] 确认 `data/instance.json` 没有被提交到任何仓库
- [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.sqlite`)
- [ ] 已规划备份(SQLite 库是唯一正本);备份文件同样受 `cookie_key` 保护,需按机密对待
- [ ] 升级到 1.2.0 后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是
- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人才给管理员
- [ ] 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是
「已保存但无法解密」
- [ ] 升级到 1.3.0 后确认「任务管理」里调度时刻**对普通账号是只读的**
(用普通账号点一次「保存」应被拒并点名越权项)