feat: 新增备份恢复与公网加固
- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底) - 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文) - 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址 - 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次 - .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发 - 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效 - 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409 - 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600 - 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
这个提交包含在:
+188
@@ -8,6 +8,194 @@
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] — 2026-09-16
|
||||
|
||||
**主题:备份管理 · 对外提供服务的安全加固 · 资源与频率限制**
|
||||
|
||||
三件事:① 补齐「备份 / 恢复 / 导出本人数据」这条数据安全链路;② 修掉三类 P0 与
|
||||
一批 P1;③ 让这个程序可以**安全地暴露到公网**——资源在容器层限制、任务频率在实例层
|
||||
限制、采集跨度有硬上限。
|
||||
|
||||
**数据不会丢**:`manage.py init` 检测到 `PRAGMA user_version` 3 → 4 时自动迁移,
|
||||
只做一件事(`ALTER TABLE users ADD session_ver`,非空、默认 0)+建一张新表
|
||||
`backups`。**无数据搬运、无键位变动**,可重复执行。
|
||||
|
||||
---
|
||||
|
||||
### 备份管理(新功能)
|
||||
|
||||
- **`workbuddy_portal/backup.py`(新模块)** —— 备份的全部逻辑
|
||||
- **快照走 SQLite 在线备份 API**(`Connection.backup`),不是 `cp`。
|
||||
它按页复制并持有读事务,所以**采集正在写的时候拿到的也是「某一时刻的完整库」**。
|
||||
手工 `cp data/usage.sqlite` 做不到这点:`-wal` 里可能还有没落盘的帧,
|
||||
拷出来的库会缺最近一段数据,而且**不报错**。
|
||||
- **归档 = 一个 zip**:所有 `data/*.sqlite` + `manifest.json` + `instance.json`。
|
||||
单文件、可校验、可搬到别的机器恢复。
|
||||
- **`backup_keep` 保留份数**:超出后按时间删最旧的(只按**磁盘上真实存在**的算份数,
|
||||
已经被手工删掉的条目不占名额)。
|
||||
- **`backups` 表 + 磁盘双向对齐**:`sync_index()` 把磁盘上的归档登记进表、
|
||||
把消失的标记 `missing=1`(不删行,保留「这里曾有过一份」的痕迹)。
|
||||
**磁盘是事实来源**,表只是缓存 —— 手工拷进来/手工删掉都能被正确呈现。
|
||||
- **管理员页面 `/backups`**(导航在「用户管理」前):KPI(份数 / 占用 / 自动备份状态 /
|
||||
上次与下次)、自动备份设置表单、归档内容说明、备份列表(下载 / 恢复 / 删除)。
|
||||
- **接口**(全部仅管理员):
|
||||
`GET /api/backups`、`POST /api/backups`、`GET /api/backups/<filename>`(下载)、
|
||||
`POST /api/backups/<filename>/restore`、`POST /api/backups/<filename>/delete`、
|
||||
`POST /api/backups/prune`。
|
||||
- **恢复的三条设计决定**(每条都对应一个会真的踩到的坑)
|
||||
1. **SQL 级整表替换,不做文件 swap**。解包 → 临时库先迁移到当前 schema →
|
||||
一个 `BEGIN IMMEDIATE` 事务里整表搬过去。好处:不需要停机、不依赖「没有别人
|
||||
持有文件句柄」(Windows 上文件 swap 会因句柄占用直接失败)、备份是旧版本
|
||||
(uv=2/3)也能恢复、中途失败就是一次回滚。
|
||||
2. **列名交集而非 `SELECT *`**。老库的列是 `ALTER` 追加的,顺序与新库建表语句
|
||||
不一定一致 —— `SELECT *` 会**静默错位**,字段整体串位而值都「合法」,
|
||||
是最难查的一类数据损坏。
|
||||
3. **恢复前自动打一份 `pre-restore` 快照**。恢复错了还能回到恢复之前。
|
||||
- **恢复后让所有会话失效**:把所有账号的 `session_ver` +1。光靠「从归档里搬
|
||||
`session_ver`」是不够的 —— 在打备份**之前**登录的那个人,他那张 Cookie 里的 `sv`
|
||||
正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被
|
||||
这次恢复整个换掉了。
|
||||
- **`instance.json` 在归档里是必要的,不是顺手加的**:没有 `cookie_key` 就永远
|
||||
解不开 `settings` 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
|
||||
代价是归档本身含密钥 ⇒ 它**永不入库、永不进镜像**(见下方 P0-3)。
|
||||
恢复时可以 `include_instance=false` 只搬数据、保留本机当前密钥。
|
||||
- **`safe_name()` 路径穿越收口**:下载与恢复接口都直接吃文件名,
|
||||
这里做 basename 收敛 + 后缀 + 字符白名单(实测拦住 `../../etc/passwd`、
|
||||
`x.txt`、`''`、`..`、`a b.zip`)。
|
||||
- **`_extract_safely()` 防 zip slip**:归档可能是**外部给的**,
|
||||
`extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外。
|
||||
- **普通用户导出本人全部数据**:`GET /profile/export`(任何登录用户,10 秒限速),
|
||||
用 `SpooledTemporaryFile(max_size=16MB)` 边生成边下发,含 4 份 CSV/JSON
|
||||
(使用记录 / 采集历史 / 操作审计 / 我的账号与配置)+ 说明。
|
||||
**不含 Cookie 明文** —— 导出自己的数据不等于把凭证交出去。
|
||||
- **CLI**:`manage.py backup [--note]` / `backups [--prune] [--keep N]` /
|
||||
`restore <文件名> --yes`(破坏性操作必须显式 `--yes`,不加只打印将要发生什么)。
|
||||
- **自动备份**:`backup_enabled` / `backup_interval_hours` / `backup_keep` 三个实例级键。
|
||||
由 `scheduler.tick()` 每 20 秒检查一次(**有采集在跑就跳过**,不跟采集抢磁盘),
|
||||
到期就打一份并按份数清理。首次部署无需等待 —— 库里没有自动备份记录时第一轮 tick 即触发。
|
||||
|
||||
### P0 修复
|
||||
|
||||
- **P0-1 `X-Forwarded-For` 可伪造 → 三道 IP 防线全废**
|
||||
- 实测:修改前每次换一个伪造的 `X-Forwarded-For`,45 次验证码请求**全部放行**;
|
||||
验证码限速、注册配额、登录锁定三道防线同时失效。
|
||||
- 新增 **`security.client_ip()`** —— 全站**唯一**取客户端地址的入口。
|
||||
默认**不信任** XFF,直接用 `remote_addr`;`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP
|
||||
(最近的一跳由你自己的代理写入,客户端伪造不了),含 `IPv4:port` 与 IPv6 处理。
|
||||
- 原来散落在 `views.py` / `api.py` 的 6 处 `request.remote_addr` 全部改走它。
|
||||
- **P0-2 默认口令 `admin123` 硬编码兜底**
|
||||
- 现在 `WB_ADMIN_PASSWORD` 为空时,程序用 `secrets.token_urlsafe(12)` 生成随机口令,
|
||||
**只在启动日志里打印一次**,且**不写进数据库**(审计日志里出现口令等于永久留档)。
|
||||
`manage.py init` 会用醒目的方框打印它 —— 库里只有散列,日志一滚就再也拿不回来。
|
||||
- `docker/entrypoint.sh` 里那句「默认密码是 admin123」的提示同时删除(它会给出一个
|
||||
永远登录不上的口令)。
|
||||
- **P0-3 `.dockerignore` 漏了 `backups/` → 凭证密文被打进镜像**
|
||||
- `backups/` 里躺着 `usage.sqlite.bak-pre-v13`(4.5 MB 的**真实生产库**快照),
|
||||
含 `settings` 的凭证密文与 `users` 的口令散列。而 `.dockerignore` 里没有任何
|
||||
规则能匹配 `backups/` ⇒ `docker build` 会把它原样烤进镜像层,
|
||||
**推一次镜像等于把整库密钥分发给所有能拉镜像的人**。
|
||||
- 补 `backups/` 与 `data/demo/`;并在 Dockerfile 里加一条**构建期断言**:
|
||||
`COPY` 之后若 `/app/backups` 非空就直接构建失败。
|
||||
靠「记得改 .dockerignore」不可靠 —— 让构建自己拒绝。
|
||||
注意这不能靠 `RUN rm` 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层。
|
||||
|
||||
### P1 修复
|
||||
|
||||
- **采集与导出没有任何跨度上限与频率限制**:一个注册账号就能反复打 `/api/collect`
|
||||
把云端与线程池占满。现在 `/api/collect` 有**三道闸门**:
|
||||
① 采集锁存在 → `409`(**有任务在跑就不能新建任务**);
|
||||
② `action_allowed("collect:uid", gap)` → `429`(默认最小间隔 60 秒);
|
||||
③ 跨度超过上限 → `400`。
|
||||
- **`COLLECT_MAX_RANGE_DAYS_HARD = 31` / `SCHEDULE_SLOTS_HARD_MAX = 12`**:
|
||||
代码层**硬顶**。`collect_max_range_days` / `max_schedule_slots_per_day` 只是更严的
|
||||
旋钮,**改大也突破不了** —— 上限必须由代码兜底,不能只靠写入校验。
|
||||
历史脏数据、手工改库都过不去。(用户要求:「最长跨度的为 1 个月」)
|
||||
- **`action_allowed(key, min_interval)` 通用重操作限速**:采集 / 导出 / 导出本人数据 /
|
||||
`vacuum` / 补全 prompt / 重算 各有最小间隔(`{"fill-prompt":60, "export-csv":15,
|
||||
"vacuum":120, "recount":5}`,采集与 CSV 导出见各自常量)。
|
||||
- **`WB_COOKIE_SECURE` 默认 0、无 HSTS**:`_env_flag()` 统一解析布尔环境变量;
|
||||
`apply_security_headers()` 在 **`COOKIE_SECURE or FORCE_HTTPS`** 时下发 HSTS
|
||||
(**只在真正走 HTTPS 时下发** —— 纯 HTTP 部署下发会让浏览器强升 https,
|
||||
表现成白屏,是个很难归因的故障)。
|
||||
- **账号锁定可以被当武器**:知道用户名就能把对方锁死 10 分钟,而**管理员用户名在导航栏里
|
||||
是公开的**。改成双维度、强度刻意不同:
|
||||
IP 维度真锁(`MAX_LOGIN_FAILS` + `LOGIN_LOCK_MINUTES`),
|
||||
用户名维度只做秒级递增退避(`USER_SOFT_THRESHOLD` / `USER_SOFT_CAP_SECONDS=60`)。
|
||||
另外加**单 IP 登录尝试总量**(`LOGIN_ATTEMPTS_PER_IP=40` / `LOGIN_ATTEMPTS_WINDOW=300`,
|
||||
**含成功**)挡住「慢慢撞、不触发失败阈值」的形态。
|
||||
登录失败提示也从「剩余 N 次」改成「本来源连续失败 N 次」—— 不再给攻击者倒计时。
|
||||
- **改密码不失效其他会话**:新增 `users.session_ver`(`DB_SCHEMA_VERSION` 3 → 4),
|
||||
`current_user()` 每个请求把会话里的 `sv` 与库里比对,不等就丢会话。
|
||||
改自己密码时会**把当前会话刷新到新版本**(否则改完立刻被自己踢下线)。
|
||||
管理员重置口令 / 停用账号 / 删除账号同样 `bump_session_ver()` —— 停用立即生效,
|
||||
不用等会话过期。
|
||||
- **验证码强度不足**(字模在源码里、只整体放大 5 倍、无扭曲,容易被模板匹配):
|
||||
改为让**同一字符两次渲染尽量不同** —— 逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、
|
||||
波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。
|
||||
实测同一验证码两次渲染字节差异 **76.9%**,字符仍可辨认。
|
||||
- **`instance.json` 未设权限**:POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,
|
||||
同机其他用户可读)。恢复时写回也走同一处理。
|
||||
- **无访问日志**:waitress 自己不记 access log,出事无据可查。
|
||||
新增 `security._access_log()`(跳过 `/static/` 与 `/captcha.png`,写进 `logs/app.log`),
|
||||
由 `WB_ACCESS_LOG` 控制(默认开)。
|
||||
- **`api_base` 可指向云元数据地址**:拦截 `169.254.169.254` /
|
||||
`metadata.google.internal` / `[fd00:ec2::254]` —— 这是 SSRF 拿云上临时凭证最经典的一跳,
|
||||
而没有任何合法采集场景需要它。
|
||||
- **口令黑名单**:`WEAK_PASSWORDS`(34 个自动撞库字典的头几页)。
|
||||
只在**设置/修改**口令时校验,登录不校验 —— 否则会把用老口令的存量用户挡在门外。
|
||||
|
||||
### 新增(配置项)
|
||||
|
||||
`GLOBAL_KEYS` 17 → **23** 键。新增的 6 个都是实例级:
|
||||
|
||||
| 键 | 默认 | 范围 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `max_schedule_slots_per_day` | 6 | 1~12 | 每日调度时刻数上限(挡住「填 200 个时刻」) |
|
||||
| `collect_min_interval_seconds` | 60 | 0~3600 | 同一账号两次手动采集的最小间隔 |
|
||||
| `collect_max_range_days` | 31 | 1~31 | 单次采集的最长跨度(硬顶 31 天 = 1 个月) |
|
||||
| `backup_enabled` | 1 | 0/1 | 是否开启自动备份 |
|
||||
| `backup_interval_hours` | 24 | 1~720 | 备份周期 |
|
||||
| `backup_keep` | 7 | 1~100 | 保留最近几份 |
|
||||
|
||||
新增环境变量:`WB_TRUST_PROXY`、`WB_FORCE_HTTPS`、`WB_ACCESS_LOG`、`WB_THREADS`、
|
||||
`WB_BACKUP_DIR`、`WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT`(compose 用)、
|
||||
`WB_HOST_BACKUP_DIR`(叠加层用)。
|
||||
|
||||
### 部署与外网暴露
|
||||
|
||||
- **`BACKUP_DIR` 刻意不在 `data/` 里面**:容器里 `data/` 是数据卷,
|
||||
`docker compose down -v` 会把正本与副本一起删 —— 那正好是最需要备份的时刻。
|
||||
新增 `WB_BACKUP_DIR`(容器里 `/app/backups`)+ compose **命名卷 `wb_backups`**。
|
||||
**绝不退回绑定挂载**(Windows 9p 下容器会 `unable to open database file`)。
|
||||
- **容器资源上限**(用户要求「资源从容器上限制」):
|
||||
`cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` 与 `mem_limit` **相等**(= 禁用 swap,
|
||||
这样超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/
|
||||
`pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`
|
||||
(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库)。
|
||||
- **`manage.py serve` 的线程数改为 `config.THREADS`**(原为硬编码 8):
|
||||
它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复),是资源上限的一部分。
|
||||
1 核配 8 线程容易出现「都在等 CPU」的假并发,容器默认给 4。
|
||||
- 新增 `docker-compose.yml` 里 `WB_TRUST_PROXY` / `WB_FORCE_HTTPS` /
|
||||
`WB_COOKIE_SECURE` 三者相邻并写明「必须一起决定」—— 拆开写很容易出现
|
||||
「开了强制 HTTPS 却忘了 Secure」这类半截配置。
|
||||
|
||||
### 其它
|
||||
|
||||
- `scheduler.py` 的 `tick()` 末尾增加自动备份检查(在账号循环**之外**,因为它是
|
||||
实例级、与具体账号无关)。
|
||||
- `collect.py` 新增 `max_range_days(conn)` / `min_interval_seconds(conn)`:
|
||||
接口、页面、`sync()` 三处共用**唯一口径**(此前会出现「页面显示的限值」与
|
||||
「接口实际校验的限值」不是同一个数的情况)。
|
||||
- `sync()` 因跨度上限收窄起点时打一条 `[warn] 请求跨度超过上限 %d 天,已自动收窄起点`。
|
||||
- `backup.sync_index()` 现在从 manifest 里读真实的 `trigger` / `actor` / `note`,
|
||||
不再一律标成 `external` —— 恢复前最需要判断的恰恰是「这份是自动备份、
|
||||
还是我手工留的、还是恢复前系统自动存的那一份」。
|
||||
- **新增页**:`/backups`(仅管理员)、`/profile/export`(任何登录用户)。
|
||||
- **测试**:`tools/smoke.py` 与 `tools/check_live.py` 补备份链路、越权、
|
||||
跨度上限、并发拒绝、会话失效断言。
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] — 2026-09-16
|
||||
|
||||
**主题:权限收敛 · 配置作用域统一 · 目录规范化**
|
||||
|
||||
在新工单中引用
屏蔽一个用户