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

29 KiB

安全策略(Security Policy)

支持范围

本项目按「自托管」定位开发,1.4.0 起已按「可以暴露到公网」加固(IP 来源、限速、 资源上限、采集跨度硬顶都补上了),1.5.0 又按同一前提收口了一轮信息泄漏、注入与读接口滥用。 即便如此,仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。

版本 是否接受安全修复
1.5.x(当前) ✅
1.4.x ⚠️ 可用,但建议升级(1.5.0 收口了内部异常/表名外泄、响应头注入、导出路径穿越、读接口无刹车)
1.3.x ⚠️ 可用,但公网暴露前必须升级(1.4.0 修掉了三道 IP 防线可被 XFF 伪造绕过的问题)
< 1.3 ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下)
< 1.2 ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题)

1.5.0 收口了什么(都不是「可直接利用的高危漏洞」,而是对外时迟早出事的那一类):

  1. 信息泄漏:接口的兜底异常原本把 str(e) 原样回给客户端(含绝对路径与 SQL 片段), 现在只回一个 8 位事件号,细节进服务端日志;/api/manifest / /api/bundle 里的 表名与库文件路径也只对管理员下发。另外补掉了登录接口的用户名枚举时序侧信道 (账号不存在时也走一次同代价的哑哈希)。
  2. 注入:导出文件名含用户名,而用户名并不总是注册正则的产物 (manage.py passwd 建号、老库升级上来的名字都可能带引号或 CR/LF)—— 直接拼 Content-Disposition 就是响应头注入;export_csv 的账号名直拼路径则是 路径穿越。两者都改为先收敛字符集,建号也补上了用户名校验。
  3. 资源滥用:读接口原本没有任何刹车,一个注册账号循环调 /api/bundle 就能持续吃满 CPU 与出口带宽。现在 /api/* 按账号(未登录按 IP)限速 240 次 / 60 秒。
  4. 隐私落盘:进程 umask 收到 0077 —— 数据目录里是对话正文、凭证密文、主密钥 与它们的明文导出副本,默认 0644 等于同机任何用户可读。
  5. 调试器:--debug 不再允许绑定对外地址(Werkzeug 调试器 = 任意代码执行, 而 --host 的默认值恰好是 0.0.0.0)。

1.4.0 修掉了什么(三条 P0):

  1. X-Forwarded-For 可伪造 ⇒ 三道 IP 防线(验证码限速 / 注册配额 / 登录锁定)同时失效。 实测:每次换一个伪造的 XFF,45 次验证码请求全部放行。现在默认不信任 XFF, 全站只有 security.client_ip() 一个取客户端地址的入口。
  2. 默认口令 admin123 硬编码兜底:现在没有默认口令,留空则生成随机口令、 只在启动日志打印一次、不写进数据库。
  3. .dockerignore 漏了 backups/ ⇒ 凭证密文被打进镜像:backups/ 里那份 4.5 MB 的真实库快照含 settings 凭证密文与 users 口令散列, docker build 会把它烤进镜像层,推一次镜像等于把整库密钥分发出去。

还收掉了一批 P1:采集/导出无跨度上限与频率限制、WB_COOKIE_SECURE 默认关且无 HSTS、 账号锁定可被当武器(管理员用户名公开)、撞库不受限、验证码强度不足(可模板匹配)、 改密码不失效其他会话、instance.json 未设 0600、无访问日志。

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、/backups、/api/backups*(含下载与恢复) 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 与 users.session_ver,不等 12 小时会话过期 security.current_user
会话版本号 改密码 / 管理员重置口令 / 停用 / 删除账号 → bump_session_ver(),该账号所有其他设备上的会话立刻作废。改自己密码时把当前会话刷到新版本(否则改完立刻被自己踢下线)。恢复备份会让全站所有会话作废 db.session_ver_of / db.bump_session_ver
自锁保护 管理员不能停用 / 降权 / 删除自己;也不能删掉最后一个启用的管理员 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
客户端 IP 唯一入口 security.client_ip() —— 默认不信任 X-Forwarded-For(直接用 remote_addr);WB_TRUST_PROXY=1 时取最右侧合法 IP(最近一跳由你自己的代理写入,客户端伪造不了)。含 IPv4:port 与 IPv6 处理。所有 IP 相关判断(验证码限速 / 注册配额 / 登录限速 / 审计留痕)都走它 security.client_ip
强制 HTTPS WB_FORCE_HTTPS=1 时非 HTTPS 的 GET/HEAD 跳转(POST 不跳,否则会掉请求体);WB_TRUST_PROXY 关闭时不读 X-Forwarded-Proto security.needs_https_redirect
口令存储 加盐哈希(PBKDF2-SHA256),不存明文;强度校验(≥8 位、含两类字符、不得等于用户名)+ 黑名单 WEAK_PASSWORDS(34 个撞库字典头几页)。黑名单只在设置/修改口令时生效,登录不校验 —— 否则会把用老口令的存量用户挡在门外 security.hash_password / password_problem
初始口令不给默认值 WB_ADMIN_PASSWORD 留空 → secrets.token_urlsafe(12) 随机生成,只在启动日志打印一次、不写进数据库(审计里出现口令等于永久留档) db._create_first_admin
开放重定向 登录后的 next 只允许站内相对路径,//evil.com 一律回落到 / security.safe_next
失败限速(双维度、强度刻意不同) IP 维度:连续失败 5 次真锁 10 分钟。用户名维度:只做秒级递增退避(封顶 60 秒)—— 「知道用户名就能把对方锁死 10 分钟」本身是攻击,而管理员用户名在导航里是公开的。另有单来源登录尝试总量 40 次 / 5 分钟(含成功),挡「慢慢撞、不触发失败阈值」。计数表有上限与 TTL security.auth_block_reason / note_try / try_window_left
失败提示不泄漏信息 提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时 web/views.py:login
用户名枚举的时序侧信道 口令校验对不存在的账号也走一次同代价的哑哈希(_DUMMY_HASH)。否则「账号不存在」比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在 security.login_ok / _dummy_verify
响应头 CSP(frame-ancestors 'none')、X-Frame-Options: DENY、nosniff、Referrer-Policy: same-origin、COOP、Permissions-Policy(显式关掉地理位置/麦克风/摄像头/支付/USB);/api/* 与 /captcha* 带 no-store security.apply_security_headers
HSTS 只在真 HTTPS 时下发 COOKIE_SECURE or FORCE_HTTPS 才发 Strict-Transport-Security。纯 HTTP 部署下发会让浏览器强升 https,表现成白屏 —— 很难归因的一类故障 security.apply_security_headers
访问日志 waitress 自己不记 access log;本程序补上(跳过 /static/ 与 /captcha.png),写进 logs/app.log,WB_ACCESS_LOG 控制 security._access_log

多用户数据隔离

项 做法 位置
强隔离 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
抗模板匹配 让同一字符两次渲染尽量不同:逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。实测同一验证码两次渲染字节差异 76.9%,字符仍可辨认 captcha._draw_char / captcha._brush_line
答案不进会话 答案只写服务端 captchas 表;会话里仅存随机 id —— Flask 会话是「签名不加密」的,放答案等于送答案 security.issue_captcha
一次性 校验后立即删除,且先删后判;5 分钟过期、按 purpose 隔离,不能拿注册的题去登登录 captcha.verify
先验码后验密 登录先校验验证码再比对口令,避免攻击者拿「密码对不对」当提前信号跑完字典 web/views.py
出图限速 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径)。来源按 client_ip() 判定,伪造 XFF 无效 security.captcha_fetch_allowed
注册配额 同 IP 每日最多注册 N 个(默认 3,可改);allow_register=0 可整体关闭。同样按 client_ip() 判定 security.register_quota

信息泄漏收敛(v1.5.0)

不致命,但都是踩点阶段最好用的材料,所以统一收掉。

项 做法 位置
内部异常不外泄 接口的兜底 except Exception 不再回 str(e)(那会带绝对路径、SQL、sqlite3 报错),改为完整堆栈进服务端日志 + 一个 8 位事件号。面向用户写的业务异常(BadParam / Busy / NotReady / ApiError / BackupError)不受影响,它们本来就是给用户看的 web/api.py::_internal
内部实现清单只给管理员 /api/manifest、/api/bundle 的 sources(表名)与 archive(库文件路径)对普通账号为空;大屏页「数据源」卡片随之收起 query.manifest(sources=…)
响应头注入收口 导出文件名一律先收敛成 ASCII 安全名(只留 [A-Za-z0-9._-]、打平 ..),再按 RFC 5987 附上原名。含引号 / CR / LF 的用户名不可能再拼出畸形响应头 security.safe_filename / content_disposition
导出路径穿越收口 export_csv 的账号名 tag 同样收敛,且结果固定落在 EXPORT_DIR 之下。根因也堵了:manage.py passwd 建号时补上用户名校验(与注册页同一套) collect.export_csv、manage.py
上传体量反馈一致 413 返回 JSON(与其它接口错误同形状)。不加这一层时 Flask 吐 HTML 页,前端 r.json() 会炸成「Unexpected token <」 workbuddy_portal/__init__.py
访问日志不带查询串 _access_log 记的是 request.path —— 若记 ?q=<搜索词>,prompt 片段会顺着搜索词进日志文件 security._access_log

资源与频率限制(对外提供服务时)

单写者架构下不加副本扛负载,所以限制必须落在「单实例资源」与「单账号频率」两处。

项 做法 位置
采集三道闸门 POST /api/collect:① 采集锁存在 → 409(有任务在跑就不能新建任务);② action_allowed("collect:uid", gap) → 429(默认最小间隔 60 秒);③ 跨度超上限 → 400 web/api.py:api_collect
采集跨度硬顶 COLLECT_MAX_RANGE_DAYS_HARD = 31(= 1 个月)。collect_max_range_days 只是更严的旋钮,改大也突破不了 —— 上限由代码兜底,不依赖写入校验。历史脏数据、手工改库都过不去 config.COLLECT_MAX_RANGE_DAYS_HARD、collect.max_range_days
调度时刻数硬顶 SCHEDULE_SLOTS_HARD_MAX = 12,另有可配置的更严上限 max_schedule_slots_per_day(默认 6)。时刻数量直接决定采集频次,是「一个账号能不能把云端打满」的开关 config.normalize_setting
重操作最小间隔 采集 60s / CSV 导出 15s / 导出本人数据 10s / vacuum 120s / 补全 prompt 60s / 重算 5s;超限 429 并给出剩余等待秒数 security.action_allowed
读接口限速 /api/* 按「账号」计数(未登录退化成来源 IP,不做 DB 回查)限速 240 次 / 60 秒,超限 429。重点挡的是 /api/bundle —— 它要算全量逐日聚合、还会下发最多 2 万条明细,把它放进 for 循环就能持续吃掉 CPU 与出口带宽(这不需要任何漏洞)。阈值刻意宽松:大屏切一次筛选只发 1~2 个请求 security.api_rate_ok
容器资源上限 cpus: 1.0 / mem_limit: 512m / memswap_limit 与 mem_limit 相等(= 禁用 swap:超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/ pids_limit: 256(挡 fork 炸弹)/ ulimits.nofile 4096:8192(SQLite 除主库外还持有 -wal -shm,恢复期还要开临时库 + ATTACH 源库) docker-compose.yml
线程数受控 WB_THREADS(容器默认 4,代码默认 8)决定单实例能同时吃进几个慢请求;1 核配 8 线程容易出现「都在等 CPU」的假并发 config.THREADS
SSRF 收口 api_base 拒绝云元数据地址(169.254.169.254 / metadata.google.internal / [fd00:ec2::254])—— SSRF 拿云上临时凭证最经典的一跳,且无任何合法采集场景需要它 config.normalize_setting

备份与数据导出

项 做法 位置
归档含密钥,这是有意的取舍 归档里的 instance.json 带 cookie_key —— 没有它就永远解不开 settings 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。代价是归档本身是最高机密,因此它永不入库、永不进镜像 backup.INSTANCE_NAME
不进镜像 .dockerignore 排除 backups/;Dockerfile 在 COPY 之后有一条构建期断言,/app/backups 非空即构建失败。不能靠 RUN rm 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层 .dockerignore、Dockerfile
文件名收口 safe_name() 对下载与恢复接口吃进来的文件名做 basename 收敛 + 后缀 + 字符白名单(防 ../../etc/passwd、绝对路径、sub/..) backup.safe_name
防 zip slip _extract_safely() 只按基名解压 —— 归档可能是外部给的,extractall 会因为条目名里的 .. 写到目录之外 backup._extract_safely
恢复前护栏 自动先给当前库打一份 pre-restore 快照;恢复错了还能回去 backup.restore
恢复后全站会话作废 所有账号 session_ver +1。光靠「从归档里搬 session_ver」不够:在打备份之前登录的那个人,Cookie 里的 sv 正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被换掉了 backup.restore
导出不含凭证 /profile/export 给 4 份 CSV/JSON 与说明,不含 Cookie 明文 —— 导出自己的数据不等于把凭证交出去。10 秒限速 web/views.py:profile_export
快照用在线备份 API Connection.backup 而不是 cp:按页复制并持有读事务,采集正在写也能拿到一致快照。手工 cp 一个 WAL 库可能缺最近一段数据,而且不报错 backup._snapshot

其它

项 做法 位置
容器权限 运行层非 root(uid/gid 1000 app);init: true 让 tini 接管 PID 1,docker stop 能干净传到 python Dockerfile、docker-compose.yml
密钥文件权限 data/instance.json 在 POSIX 上显式 chmod 0600(默认 umask 022 会留下 0644,同机其他用户可读)。恢复时写回也走同一处理,并先把原文件另存 .pre-restore-<ts> config._instance_init、backup._restore_instance
进程级落盘权限(v1.5.0) 启动时把 umask 收到 0077,此后本进程新建的一切文件天然是 owner-only:SQLite 的 -wal / -shm、导出 CSV、备份 zip、日志一并覆盖。刻意选「一条设置管全部」而不是逐个 chmod —— 逐点 chmod 一定会漏,而漏掉的那个文件里往往正好是最新的对话正文。配套 harden_dir() / harden_file() 收紧遗留的 0755 目录与已存在的文件 config.harden_process / harden_dir / harden_file
调试器不绑对外地址 manage.py serve --debug 在 --host 非回环时直接拒绝启动(退出码 2)。Werkzeug 的交互式调试器等于任意代码执行,而 --host 默认就是 0.0.0.0 —— 这条把「本机调试习惯」和「公网部署」之间的那个坑堵死 manage.py cmd_serve
上传体量 MAX_CONTENT_LENGTH = 4 MiB(超限由 413 处理器转成 JSON) 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 # 说明 会让整行变成永不匹配的模式)。

也绝不进镜像:.dockerignore 里 backups/ 是硬要求,理由见上表(P0-3)。 Dockerfile 里那条构建期断言就是防「以后有人又把它删了」。

backups/ 同时也是一个刻意不放在 data/ 的目录:data/ 在 Docker 部署下是命名卷, docker compose down -v 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。 容器里它挂独立卷 wb_backups;绝不用绑定挂载(Windows 9p 下容器会 unable to open database file,且不自愈)。

已知的非目标(部署方需自行处理)

本项目刻意不做下面这些,请按你的环境补齐:

  • 没有强制 HTTPS:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时 不要设 WB_COOKIE_SECURE=1,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。 注意:纯 HTTP 下流量在网内是明文的,同一局域网内的中间人可以看到会话 Cookie 与 Prompt 内容。 反代配置有一个要点:开启 WB_TRUST_PROXY=1 后,程序取 X-Forwarded-For 里 最右侧的合法 IP —— 最右侧是离你最近的那一跳(由你自己的代理写入),客户端加不进去。 所以 nginx 写 $proxy_add_x_forwarded_for(保留链路,便于排查)或 $remote_addr (覆盖)都安全;真正不能做的是去信最左边那一段(那是客户端自己填的)。 不设反代直接暴露时必须保持 0,否则三道 IP 防线全部失效(见 P0-1)。
  • 没有 CSRF 之外的重放防护 / 没有 WAF:公网暴露前请置于反向代理的 rate limit 之后。 应用层已有采集/导出/恢复等重操作的最小间隔,但那只是「别自己把自己打满」, 挡不住分布式来源。
  • 备份策略只做到「本机自动 + 手动下载」:程序会按周期打快照、按份数清理、支持下载与恢复, 但不会把归档推到异地。备份留在同一台机器上只防「改错了」,不防「机器没了」 —— 请自行把归档同步到别处(那是 3-2-1 原则里属于你的那一半)。
  • 没有邮件/短信找回:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。
  • 不建议在没有任何前置防护时直接暴露到公网:1.4.0 起 IP 来源、限速、资源上限、 采集跨度硬顶都补上了,但设计前提仍是「局域网或 VPN 内使用 + 前面有反代」。
  • Cookie 的获取方式由使用者负责:手动从浏览器复制、粘贴给自己的账号。 它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。
  • cookie_key 泄露 = 所有 Cookie 泄露:data/instance.json 的权限应与数据库同级看待。 备份归档里也有一份 instance.json —— 归档的保密等级与 instance.json 完全相同。
  • 管理员在运维层面是可信角色:能登录部署机器的人可以看到数据库文件、应用日志, 理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 —— 界面层的隔离保护的是使用者之间,不是「使用者 vs 服务器管理员」。 管理员还能下载备份与执行恢复 —— 这等于对全库数据的完整读写权,授管理员前请想清楚。

部署前的最小检查清单

  • 未设 WB_ADMIN_PASSWORD 时已从启动日志抄下随机初始口令并改掉 (docker compose logs portal | grep -A6 管理员初始口令)—— 现在没有 admin123 兜底
  • 已确认是否要开放自助注册;开放时按需调小 register_max_per_ip
  • data/、logs/、backups/ 的权限只对服务账号可读写(前两者与 instance.json 同级机密)
  • 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 WB_COOKIE_SECURE=1
  • 反代是否重写了 X-Forwarded-For:是 → WB_TRUST_PROXY=1 ($proxy_add_x_forwarded_for 或 $remote_addr 均可,程序取最右侧); 否(含直接暴露)→ 保持 WB_TRUST_PROXY=0。这条搞错会让三道 IP 防线同时失效
  • 容器资源上限符合预期(docker stats 看 MEM LIMIT 是否为 512MiB;docker inspect 看 PidsLimit)
  • 确认 data/instance.json 没有被提交到任何仓库
  • backups/ 也未被提交,且已确认 .gitignore 生效(git check-ignore -v backups/x.zip)
  • 确认镜像里没有备份归档:docker run --rm <镜像> sh -c 'ls -A /app/backups' 应为空
  • 已规划异地备份:程序只负责本机打快照,归档需自行同步到别的机器/对象存储
  • 新增的账号一律用普通角色;只有确实需要维护实例的人(含下载备份与恢复)才给管理员
  • 已把「采集最小间隔 / 单次最长跨度 / 每日时刻上限」三个刹车调到符合你的预期 (「任务管理」页可改;跨度硬顶 31 天不可突破)
  • 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是 「已保存但无法解密」
  • 升级到 1.4.0 后确认「任务管理」里调度时刻对普通账号是只读的, 且访问 /backups 返回 403(用普通账号各试一次)
  • 升级到 1.5.0 后跑一次 python tools/smoke.py:第 9 节专门验「对外暴露面」 (响应头收敛 / 异常不外泄 / 读接口限速 / 安全响应头 / 普通账号拿不到数据源清单)
  • 确认容器里进程的落盘权限已收紧:docker compose exec portal sh -c 'umask' 应为 0077, 且 ls -l /app/data 的目录权限是 drwx------
  • 确认从外部看到的响应头里有 Permissions-Policy(1.5.0 新增)
  • 确认 --debug 没有被用在对外地址上:python manage.py serve --debug 应当直接报错退出