文件
workbuddy-portal/SECURITY.md
T
wangchuanli 1bf961f6b3 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

12 KiB
原始文件 Blame 文件历史

安全策略(Security Policy)

支持范围

本项目按「自托管、局域网内使用」的定位开发。安全修复只针对当前主分支与最新发布版本。

版本 是否接受安全修复
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、可复现的绕过步骤)。

请通过以下任一私有渠道联系维护者:

  • 邮件:<安全联系邮箱>(占位,待维护者补全)
  • 或通过代码托管平台(Gitea)的站内私信联系仓库管理员

请在报告里尽量包含:

  1. 受影响的版本 / 提交号
  2. 复现步骤与最小复现(可脱敏)
  3. 影响范围(能读到什么、能改到什么)
  4. 如果有,你建议的修复方向

我们会在 7 天内确认收到,并在修复发布后于 CHANGELOG 里致谢(除非你希望匿名)。

设计上已有的安全措施

理解这些边界,有助于你判断某个现象是「设计如此」还是「真的漏洞」:

身份、会话与权限

项 做法 位置
全站鉴权 每个页面都有 @login_required,每个 /api/* 未登录返回 401 JSON security.login_required、web/views.py
角色(两档) 管理员 / 普通。仅管理员:/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
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
口令存储 加盐哈希(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

多用户数据隔离

项 做法 位置
强隔离 uid 是 conn 之后的第一个位置参数且无默认值;漏传直接 TypeError,不会退化成「返回全量」 query.py / collect.py / scheduler.py
按 id 取单条也隔离 /api/records/<id>、/api/runs/<id> 的 WHERE 都带 user_id web/api.py
配置作用域与写权限对齐 「键存在哪一级」与「谁能改」是一件事: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
实例级也不存凭证 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)的保密

项 做法 位置
静态加密 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 校验 默认开启;ssl_verify 是实例级且仅管理员可改 —— 能让普通账号关掉它,等于允许把所有人的 Cookie 发往不校验证书的地址 settings.ssl_verify、config.GLOBAL_KEYS

防自动化攻击

项 做法 位置
图形验证码 手写 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、 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)。
  • 没有邮件/短信找回:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。
  • 不建议直接暴露到公网:设计前提是局域网或 VPN 内使用。
  • Cookie 的获取方式由使用者负责:手动从浏览器复制、粘贴给自己的账号。 它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。
  • cookie_key 泄露 = 所有 Cookie 泄露:data/instance.json 的权限应与数据库同级看待。
  • 管理员在运维层面是可信角色:能登录部署机器的人可以看到数据库文件、应用日志, 理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 —— 界面层的隔离保护的是使用者之间,不是「使用者 vs 服务器管理员」。

部署前的最小检查清单

  • 已修改默认管理员口令(WB_ADMIN_PASSWORD),不再是 admin123
  • 已确认是否要开放自助注册;开放时按需调小 register_max_per_ip
  • 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 保护,需按机密对待
  • 新增的账号一律用普通角色;只有确实需要维护实例的人才给管理员
  • 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是 「已保存但无法解密」
  • 升级到 1.3.0 后确认「任务管理」里调度时刻对普通账号是只读的 (用普通账号点一次「保存」应被拒并点名越权项)