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 配置
这个提交包含在:
2026-09-18 08:46:34 +08:00
父节点 1bf961f6b3
当前提交 3751dffef9
共修改 30 个文件,包含 3150 行新增和 241 行删除
+4 -1
查看文件
@@ -47,6 +47,7 @@ CAPTURES = [
("06-users.png", "/users", "用户管理", True),
("07-dashboard.png", "/dashboard", "用量大屏", True),
("10-profile.png", "/profile", "个人中心", True),
("11-backups.png", "/backups", "备份管理(仅管理员)", True),
]
# v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态,
@@ -237,7 +238,9 @@ def main() -> int:
for name, path, label in USER_CAPTURES:
shoot(name, path, label, pg=page2)
# 顺带把越权面再验一次:普通账号访问这些必须不是 200
for probe in ("/logs", "/logs/tail", "/users"):
# /backups 与 /api/backups 是 v1.4.0 新增的:能下载或恢复整个数据库
# 等于对全库数据有完整读写权,所以必须只给管理员。
for probe in ("/logs", "/logs/tail", "/users", "/backups", "/api/backups"):
r = page2.goto(a.base + probe, wait_until="domcontentloaded")
code = r.status if r else 0
ok = code in (403, 401)
+101
查看文件
@@ -97,6 +97,11 @@ def run() -> None:
from workbuddy_portal import captcha, config, create_app, crypto, db, query, security
print("== 0. 构建应用 ==")
# 先把真实库迁到与代码同版本。init_db 是幂等的,顺带把迁移路径也验一遍。
# 少了这一步,「库还停在旧 schema」会被 current_user() 判成「未登录」,
# 现场表现是下面所有页面断言集体变 302 —— 看起来像产品坏了,
# 其实只是前置条件没满足。
db.init_db()
app = create_app(start_scheduler=False, do_init_db=False)
app.config["WTF_CSRF_ENABLED"] = False
n_routes = len([r for r in app.url_map.iter_rules()])
@@ -105,6 +110,9 @@ def run() -> None:
# 真实库里的账号:管理员必须有,普通账号按需临时造
conn = db.connect()
uv = conn.execute("PRAGMA user_version").fetchone()[0]
chk("库 schema 已与代码同版本", uv == db.DB_SCHEMA_VERSION,
"uv=%d 期望=%d" % (uv, db.DB_SCHEMA_VERSION))
admin_row = conn.execute("SELECT id FROM users WHERE is_admin=1 AND status='active'"
" ORDER BY id LIMIT 1").fetchone()
if admin_row is None:
@@ -673,6 +681,99 @@ def run() -> None:
missing = sorted(c for c in used - css_classes - allow)
chk("无「用了但 CSS 里不存在」的类名", not missing, "缺失=%s" % missing if missing else "")
# ---------------- 8. 备份 ----------------
# 这一节盯三件事:
# ① `safe_name` 是下载/恢复接口**唯一**吃文件名的收口点。漏检就是任意文件
# 读取 —— `../../data/instance.json` 能直接把主密钥拿走;
# ② 归档里必须同时有 manifest / 主库 / instance.json。少了 instance.json,
# settings 里的凭证密文就永远解不开了(cookie_key 在里面);
# ③ 备份是管理员专属能力,普通账号连列表都不能看。
# 「破坏 → 恢复 → 比对」的往返**不在这里做**:它要整库替换,不适合对真实库
# 执行,由隔离环境里的专项验证覆盖(见 docs/DEPLOYMENT.md 8.3)。
print("== 8. 备份:路径收口 / 归档完整 / 越权面 ==")
import shutil as _sh
import tempfile as _tf
import zipfile as _zf
from workbuddy_portal import backup
for bad in ("../../etc/passwd", "x.txt", "", "..", "a b.zip", "a.zip/../../x.zip"):
try:
got = backup.safe_name(bad)
# `a.zip/../../x.zip` 这类会被 basename 收敛成合法的 `x.zip`。
# 收敛不算漏检,但结果里**必须**不含任何路径成分。
bad_ok = bool(got) and got == os.path.basename(got) \
and "/" not in got and "\\" not in got and ".." not in got
except backup.BackupError:
bad_ok = True
chk("⑧ safe_name 收口 %r" % bad, bad_ok)
real_dir = config.BACKUP_DIR
tmp_bk = _tf.mkdtemp(prefix="wb-smoke-bk-")
# 第 5 节末尾把 conn 关掉了(那是它自己的收尾动作),这里重新拿一个独立的。
bconn = db.connect()
seen_before = {r["filename"] for r in bconn.execute("SELECT filename FROM backups")}
try:
config.BACKUP_DIR = tmp_bk # 别把真实 backups/ 搅乱
res = backup.create(bconn, "manual", "smoke", "smoke 断言")
ap = backup.path_of(res["filename"])
chk("⑧ 备份生成成功", bool(res["ok"]) and os.path.exists(ap), res["message"])
with _zf.ZipFile(ap) as z:
names = set(z.namelist())
chk("⑧ 归档含 manifest.json", backup.MANIFEST in names)
chk("⑧ 归档含主库快照", os.path.basename(config.SQLITE_PATH) in names)
chk("⑧ 归档含 instance.json(否则 cookie_key 丢失)",
backup.INSTANCE_NAME in names)
v = backup.verify(ap)
chk("⑧ verify 通过", v["integrity"] == "ok",
"uv=%s 条数=%s 账号=%s" % (v["schema_ver"], v["records"], v["users"]))
chk("⑧ verify 报的条数与库一致",
v["records"] == bconn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0],
"records=%s" % v["records"])
backup.create(bconn, "manual", "smoke", "第二份")
backup.sync_index(bconn)
backup.prune(bconn, keep=1, actor="smoke")
left = [f for f in os.listdir(tmp_bk) if f.endswith(backup.SUFFIX)]
chk("⑧ prune keep=1 后只剩 1 份", len(left) == 1, "剩=%d" % len(left))
tmp_user = "smoke_b_%s" % _rand()
bconn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
"status,created_at) VALUES(?,?,'备份越权探针',0,'active',?)",
(tmp_user, security.hash_password("Smoke-Pass1"), db.now_str()))
bid = bconn.execute("SELECT id FROM users WHERE username=?", (tmp_user,)).fetchone()["id"]
try:
with app.test_client() as cb:
login(cb, bid)
for p in ("/backups", "/api/backups"):
st, _ = page(cb, p)
chk("⑧ 普通账号 GET %s 被拒" % p, st in (403, 401), "status=%s" % st)
# 导出是 zip,不能走 page()(它按文本解码,会炸在二进制上)
er = cb.get("/profile/export")
body = er.get_data()
try:
with _zf.ZipFile(io.BytesIO(body)) as z:
zn = z.namelist()
except Exception: # noqa: BLE001
zn = []
chk("⑧ 普通账号可导出本人数据(zip)",
er.status_code == 200 and len(zn) >= 4,
"状态=%s 条目=%s" % (er.status_code, zn))
chk("⑧ 导出包里没有 cookie 明文",
not any("cookie" in n.lower() for n in zn))
finally:
bconn.execute("DELETE FROM users WHERE id=?", (bid,))
finally:
config.BACKUP_DIR = real_dir
_sh.rmtree(tmp_bk, ignore_errors=True)
# 归档文件已经随临时目录没了,登记行留着就是脏数据(列表里会显示「已丢失」)
fresh = [r["filename"] for r in bconn.execute("SELECT filename FROM backups")
if r["filename"] not in seen_before]
for n in fresh:
bconn.execute("DELETE FROM backups WHERE filename=?", (n,))
bconn.commit()
bconn.close()
def main() -> int:
print("工程目录:%s\n" % BASE)