feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码
数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
这个提交包含在:
+73
-19
@@ -6,12 +6,12 @@
|
||||
|
||||
| 版本 | 是否接受安全修复 |
|
||||
|---|---|
|
||||
| `1.1.x`(当前) | ✅ |
|
||||
| `< 1.1` | ❌ 请先升级 |
|
||||
| `1.2.x`(当前) | ✅ |
|
||||
| `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) |
|
||||
|
||||
## 如何报告漏洞
|
||||
|
||||
**请不要在公开 Issue 里贴出可直接利用的细节**(含真实 Cookie、`secret_key`、可复现的绕过步骤)。
|
||||
**请不要在公开 Issue 里贴出可直接利用的细节**(含真实 Cookie、`secret_key` / `cookie_key`、可复现的绕过步骤)。
|
||||
|
||||
请通过以下任一私有渠道联系维护者:
|
||||
|
||||
@@ -32,36 +32,90 @@
|
||||
|
||||
理解这些边界,有助于你判断某个现象是「设计如此」还是「真的漏洞」:
|
||||
|
||||
### 身份、会话与权限
|
||||
|
||||
| 项 | 做法 | 位置 |
|
||||
|---|---|---|
|
||||
| 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `workbuddy_portal/security.py`、`web/views.py` |
|
||||
| CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截 | `security.check_csrf` |
|
||||
| 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` |
|
||||
| 角色 | 管理员 / 普通两档;`/users`、`/logs/tail`、`vacuum` 等仅管理员 | `security.admin_required` |
|
||||
| 停用即失效 | `current_user()` **每个请求**回查 `users.status`,不等 12 小时会话过期 | `security.current_user` |
|
||||
| 自锁保护 | 管理员不能停用 / 降权 / 删除自己 | `web/api.py` |
|
||||
| CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` |
|
||||
| 会话签名 | Flask `secret_key` 由 `data/instance.json` 持有,首次启动随机生成 | `workbuddy_portal/config.py` |
|
||||
| 口令存储 | 加盐哈希,不存明文 | `security.hash_password` |
|
||||
| 凭据脱敏 | 云端 **Cookie 是账号凭证**:入库后页面与接口**都不回传全文**,只给「N 字符,结尾 …xxxx」 | `config.SECRET_KEYS`、`web/views.py` |
|
||||
| 开放重定向 | 登录后的 `next` 只允许站内相对路径 | `web/views.py` |
|
||||
| TLS 校验 | 默认开启,**不提供「关掉校验」的快捷开关**(Cookie 不该裸奔) | `settings.ssl_verify` |
|
||||
| 容器权限 | 运行层非 root(uid/gid 1000 `app`) | `Dockerfile` |
|
||||
| 会话 cookie | `HttpOnly` + `SameSite=Lax` + `Path=/`;HTTPS 部署可设 `WB_COOKIE_SECURE=1` 打开 Secure | `workbuddy_portal/__init__.py` |
|
||||
| 口令存储 | 加盐哈希(PBKDF2-SHA256),不存明文;强度校验(≥8 位、含两类字符、不得等于用户名) | `security.hash_password` / `password_problem` |
|
||||
| 开放重定向 | 登录后的 `next` 只允许站内相对路径,`//evil.com` 一律回落到 `/` | `security.safe_next` |
|
||||
| 失败限速 | **IP 与用户名两个维度**分别计数,任一维度连续失败 5 次锁 10 分钟;计数表有上限与 TTL | `security.auth_locked` / `note_auth_fail` |
|
||||
| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` |
|
||||
|
||||
**绝不入库**:`data/instance.json`(含 `secret_key`)、`data/usage.sqlite`、`logs/*`、`.env`。
|
||||
### 多用户数据隔离
|
||||
|
||||
| 项 | 做法 | 位置 |
|
||||
|---|---|---|
|
||||
| 强隔离 | `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` |
|
||||
| 凭证不回落 | `NO_FALLBACK_KEYS = {cookie, user_agent}` **不参与实例级回落** —— 回落等于新账号继承管理员凭证,是最严重的串号越权 | `db.get_setting` |
|
||||
| 日志隔离 | 采集运行记录按账号下发;`/logs/tail`(应用日志文件)仅管理员 | `web/views.py` |
|
||||
| 导出不互相覆盖 | `/records/export` 与 CLI `export-csv` 的文件名带账号名 | `web/views.py`、`collect.export_csv` |
|
||||
|
||||
### 云端凭证(Cookie)的保密
|
||||
|
||||
| 项 | 做法 | 位置 |
|
||||
|---|---|---|
|
||||
| **静态加密** | ChaCha20(RFC 8439 §2.3)+ HMAC-SHA256 **encrypt-then-MAC**,密文 `v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>`;手写实现,零第三方依赖 | `workbuddy_portal/crypto.py` |
|
||||
| 密钥分离 | 主密钥 `cookie_key` 与 `SECRET_KEY` **分开键位**存放(两者轮换代价不同:换 `cookie_key` 会让所有已存 Cookie 失效) | `config.encryption_key` / `secret_key` |
|
||||
| 唯一明文出口 | `db.get_secret()` 是取明文的**唯一**通道;`get_settings()` 把 `ENCRYPTED_KEYS` 一律置空,所以「顺手回传全部配置」的代码带不出凭证 | `db.py` |
|
||||
| 只回掩码 | 页面与 `/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` |
|
||||
|
||||
### 防自动化攻击
|
||||
|
||||
| 项 | 做法 | 位置 |
|
||||
|---|---|---|
|
||||
| 图形验证码 | 手写 PNG 编码器 + 5×7 点阵字模 + 干扰线/噪点;**不用 SVG**(SVG 是文本,答案会明文出现在页面源码里) | `workbuddy_portal/captcha.py` |
|
||||
| 答案不进会话 | 答案只写服务端 `captchas` 表;会话里仅存随机 id —— Flask 会话是「签名不加密」的,放答案等于送答案 | `security.issue_captcha` |
|
||||
| 一次性 | 校验后立即删除,且**先删后判**;5 分钟过期、按 `purpose` 隔离,不能拿注册的题去登登录 | `captcha.verify` |
|
||||
| 先验码后验密 | 登录先校验验证码再比对口令,避免攻击者拿「密码对不对」当提前信号跑完字典 | `web/views.py` |
|
||||
| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径) | `security.captcha_fetch_allowed` |
|
||||
| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭 | `security.register_quota` |
|
||||
|
||||
### 其它
|
||||
|
||||
| 项 | 做法 | 位置 |
|
||||
|---|---|---|
|
||||
| 容器权限 | 运行层非 root(uid/gid 1000 `app`) | `Dockerfile` |
|
||||
| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB` | `workbuddy_portal/__init__.py` |
|
||||
| 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` |
|
||||
|
||||
**绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、
|
||||
`logs/*`、`.env`。
|
||||
`.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v <file>` 逐条复核。
|
||||
注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。
|
||||
|
||||
## 已知的**非**目标(部署方需自行处理)
|
||||
|
||||
本项目刻意不做下面这些,请按你的环境补齐:
|
||||
|
||||
- **没有多租户与细粒度权限**:登录用户即为管理员,能改配置、能看全部数据。
|
||||
- **没有限流与账号锁定**:公网暴露前请置于反向代理的 rate limit 之后。
|
||||
- **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。
|
||||
- **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时
|
||||
**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。
|
||||
- **没有 CSRF 之外的重放防护 / 没有 WAF**:公网暴露前请置于反向代理的 rate limit 之后。
|
||||
- **没有备份机制**:备份策略需要你自己定(见 `docs/DEPLOYMENT.md`)。
|
||||
- **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。
|
||||
- **不建议直接暴露到公网**:设计前提是局域网或 VPN 内使用。
|
||||
- **Cookie 的获取方式由使用者负责**:手动从浏览器复制。请勿把该 Cookie 分享给他人,
|
||||
它的权限等同于你的账号;轮换后记得在「配置管理」页更新。
|
||||
- **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。
|
||||
它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。
|
||||
- **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。
|
||||
|
||||
## 部署前的最小检查清单
|
||||
|
||||
- [ ] 已修改默认管理员口令(`WB_ADMIN_PASSWORD`),不再是 `admin123`
|
||||
- [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写
|
||||
- [ ] 前面有反向代理并启用了 HTTPS
|
||||
- [ ] 已确认是否要开放自助注册;开放时按需调小 `register_max_per_ip`
|
||||
- [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写(内含 `instance.json` 的两个密钥)
|
||||
- [ ] 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 `WB_COOKIE_SECURE=1`
|
||||
- [ ] 确认 `data/instance.json` 没有被提交到任何仓库
|
||||
- [ ] 已规划备份(SQLite 库是唯一正本)
|
||||
- [ ] 已规划备份(SQLite 库是唯一正本);备份文件同样受 `cookie_key` 保护,需按机密对待
|
||||
- [ ] 升级到 1.2.0 后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是
|
||||
「已保存但无法解密」
|
||||
|
||||
在新工单中引用
屏蔽一个用户