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 配置
这个提交包含在:
+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)
|
||||
|
||||
在新工单中引用
屏蔽一个用户