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
父节点 df7db3582e
当前提交 1bf961f6b3
共修改 42 个文件,包含 2615 行新增和 854 行删除
+156 -18
查看文件
@@ -11,13 +11,18 @@
因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。
覆盖内容:
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
1. 全页面渲染(含 /users 与 /logs,均需管理员身份)——模板报错会直接暴露成 500
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
3. 历史缺陷防回归(见 4. 的 ①~⑭)
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / 权限边界
3. 历史缺陷防回归(见 5. 的 ①~⑭)
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / **普通账号的越权面**(4. 与 4b.)
5. CSV 导出可被标准 csv 解析、列数一致
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
权限模型(改断言前先读这一行):
普通账号**只能写** `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
调度、采集参数、接口地址、注册策略全部只有管理员能写,且一律存在实例级
`user_id=0`。所以「越权写」的期望结果是 **400**,而不是「写进去但看不到」。
写库说明:会写少量 audit_log 行;另外会**临时**建两个普通账号
(一个用来验权限边界,一个用来走完整注册链路),无论成功失败都在 finally 里删掉。
不会改动任何用量数据。
@@ -107,6 +112,17 @@ def run() -> None:
return
ADMIN = admin_row["id"]
# 实例级配置快照:整轮跑完必须一条不少。
# 历史缺陷:清理语句写成 `user_id NOT IN (SELECT id FROM users)`,会把
# user_id=0(实例级)当成孤儿一起删掉 —— 表现为「跑一次 smoke,全实例的
# 调度/采集参数被重置」,而且不报任何错。所以这里前后各取一次快照。
inst_before = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
chk("实例级配置非空(否则下面那条断言会空转)", len(inst_before) > 0,
"keys=%d" % len(inst_before))
chk("实例级不含凭证类键(cookie/user_agent 恒为个人级)",
not (inst_before & config.USER_EDITABLE_KEYS),
"混入=%s" % sorted(inst_before & config.USER_EDITABLE_KEYS))
# ---------------- 1. 未登录 ----------------
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
with app.test_client() as cli:
@@ -241,15 +257,31 @@ def run() -> None:
# User-Agent 本身不是秘密,新账号拿到 DEFAULTS 里的**通用** UA 是对的;
# 要守住的是「不能继承别人存下来的那一份」。用一个哨兵值把这点钉死:
sentinel = "SMOKE-SENTINEL-UA/%s" % _rand()
saved_ua_inst = db.get_setting(conn, "user_agent", "", 0)
# 注意:get_setting 在没有行时会回落到 DEFAULTS,所以「还原是否成功」
# 要拿**行为**比(读出来一样),而不是拿「行在不在」比 —— 这两件事
# 在实例级是分开的,混起来会让断言永远失败。
before_ua = db.get_setting(conn, "user_agent", "", 0)
had_row = conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
).fetchone() is not None
saved_ua_inst = before_ua if had_row else None
db.set_setting(conn, "user_agent", sentinel, 0) # 写实例级
chk("② 实例级放哨兵后,新账号仍看不到它",
db.get_setting(conn, "user_agent", "", VIEWER) != sentinel,
"new=%s…" % (db.get_setting(conn, "user_agent", "", VIEWER) or "")[:22])
chk("② 哨兵在实例级确实生效(证明上面的断言不是在空跑)",
db.get_setting(conn, "user_agent", "", 0) == sentinel)
db.set_setting(conn, "user_agent", saved_ua_inst, 0) # 还原
chk("② 已还原实例级 UA", db.get_setting(conn, "user_agent", "", 0) == saved_ua_inst)
# 还原:**原本没有这一行就删掉**。实例级本不该存在凭证类键
# (v1.3.0 起 cookie / user_agent 恒为个人级),写回空串只会留下
# 一个多余行,下次跑就会让「实例级不含凭证键」的断言失败。
if had_row:
db.set_setting(conn, "user_agent", before_ua, 0)
else:
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
chk("② 已还原实例级 UA(读出来与放哨兵前一致)",
db.get_setting(conn, "user_agent", "", 0) == before_ua)
chk("② 还原后实例级不留 user_agent 行(原本有则保留)",
(conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
).fetchone() is not None) == had_row)
# 普通配置应当能回落到实例级(否则每个新账号都拿到空配置)
chk("② 普通配置仍回落实例级",
db.get_setting(conn, "page_size", None, VIEWER) ==
@@ -362,13 +394,22 @@ def run() -> None:
chk("⑦ totals(uid=0) 不含任何人的数据",
query.totals(conn, 0)["records"] == 0)
finally:
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)。
# 原本实例级没有这一行时,**删掉**而不是写回空串 —— 见第 ② 条断言。
if saved_ua_inst is not None:
db.set_setting(conn, "user_agent", saved_ua_inst, 0)
else:
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
for name in created:
conn.execute("DELETE FROM users WHERE username=?", (name,))
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
conn.execute("DELETE FROM usage_records WHERE user_id NOT IN (SELECT id FROM users)")
# 注意 `user_id<>0` 不能省:user_id=0 是**实例级配置**(调度、采集参数、
# 接口地址、注册策略都在那里),它不属于任何账号,所以
# `NOT IN (SELECT id FROM users)` 会把它当孤儿一起删掉 ——
# 表现成「跑一次 smoke,全实例的配置被重置」,且不报任何错。
conn.execute("DELETE FROM settings WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
conn.execute("DELETE FROM usage_records WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
# 确认清理干净
left = conn.execute("SELECT COUNT(*) FROM users WHERE username LIKE 'smoke\\_%' ESCAPE '\\'"
@@ -376,7 +417,10 @@ def run() -> None:
chk("3. 临时账号已清理", left == 0, "残留=%d" % left)
# ---------------- 4. 普通账号的权限边界 ----------------
print("== 4. 非管理员:/users 必须 403,导航不出现该入口 ==")
# 规则只有一条(config.writable_by):普通账号只能写本人的 cookie / user_agent,
# 其余(调度、采集参数、接口地址、注册策略)一律 400。页面隐藏 / disabled
# 只是「不给出误导性按钮」,真正的闸门在服务端,所以这里全部走真实请求。
print("== 4. 非管理员:越权面必须全部关死 ==")
viewer2 = "smoke_w_%s" % _rand()
try:
V2 = _mk_user(viewer2)
@@ -386,18 +430,112 @@ def run() -> None:
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
for p in ("/", "/records", "/tasks", "/logs", "/config", "/profile"):
st, _ = page(cli, p)
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
# 日志尾部是管理员专属
# ④ 日志是**实例级**运行信息(含数据库路径 / 账号名 / 来源 IP),
# 普通账号整页 403 —— 不是「只看到自己那份」。
st, _ = page(cli, "/logs")
chk("GET /logs 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/logs/tail?lines=10")
chk("GET /logs/tail 非管理员=403", st == 403, "status=%s" % st)
# ⑤ 其余页面(都只渲染本人数据)必须照常能开
for p in ("/", "/dashboard", "/records", "/tasks", "/config", "/profile"):
st, _ = page(cli, p)
chk("GET %-11s 非管理员=200" % p, st == 200, "status=%s" % st)
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
chk("概览导航不含「日志管理」", "日志管理" not in html)
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
# ⑥ 越权写:调度 / 采集参数 / 实例级键,逐个试,全部必须 400
keep_times = db.get_setting(conn, "schedule_times", "", 0)
for key, val in (("schedule_times", "23:59"),
("schedule_enabled", "0"),
("catch_up", "0"),
("page_size", "1000"),
("timeout", "300"),
("ssl_verify", "0"),
("max_prompt", "0"),
("api_base", "http://evil.invalid"),
("allow_register", "0")):
st, body = page(cli, "/api/settings", method="POST", json={key: val},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑥ 越权写 %-16s =400" % key, st == 400, "status=%s" % st)
chk(" └ 报错里点名 %s" % key, key in body)
chk("⑥ 越权尝试确实没落库(schedule_times 未变)",
db.get_setting(conn, "schedule_times", "", 0) == keep_times)
chk("⑥ 实例级 api_base 未被改写",
"evil" not in db.get_setting(conn, "api_base", "", 0))
# ⑦ 但本人凭证必须写得进去(否则普通账号根本没法采集)
st, _ = page(cli, "/api/settings", method="POST",
json={"user_agent": "SMOKE-VIEWER-UA/1.0"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑦ 普通账号写本人 user_agent=200", st == 200, "status=%s" % st)
chk("⑦ 且只写进了自己名下",
db.get_setting(conn, "user_agent", "", V2) == "SMOKE-VIEWER-UA/1.0")
# ⑧ 任务页给普通账号渲染的是只读表,且没有「保存调度配置」按钮
st, tk = page(cli, "/tasks")
chk("⑧ 任务页标注调度只读", "仅管理员可改" in tk)
chk("⑧ 任务页无调度保存按钮", "保存调度配置" not in tk)
# ⑨ 配置页对普通账号只给凭证表单,采集参数渲染成只读表
st, cf = page(cli, "/config")
chk("⑨ 配置页有凭证表单", 'id="formCred"' in cf)
chk("⑨ 配置页无采集参数表单", 'id="formCollect"' not in cf)
chk("⑨ 配置页无实例级设置表单", 'id="formGlobal"' not in cf)
chk("⑨ 配置页说明范围", "唯一可以修改" in cf)
# ⑩ /api/status 的角色字段(大屏与前端靠它显隐管理员入口)
st, sj = page(cli, "/api/status")
chk("⑩ GET /api/status 普通账号=200", st == 200, "status=%s" % st)
if st == 200:
j = json.loads(sj)
chk("⑩ is_admin=False", j.get("is_admin") is False, "%s" % j.get("is_admin"))
chk("⑩ can_edit_schedule=False", j.get("can_edit_schedule") is False)
chk("⑩ can_view_logs=False", j.get("can_view_logs") is False)
finally:
conn.execute("DELETE FROM users WHERE username=?", (viewer2,))
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
# `user_id<>0` 是必须的:0 是实例级配置,不能当孤儿清理(见第 3 节的说明)
conn.execute("DELETE FROM settings WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
# ---------------- 4b. 全局键的落库位置 ----------------
# 这一节盯的是「管理员改了但只有自己生效」这类**静默** bug:
# 全局键若被写进管理员的 user_id,其它账号读取时会回落到 DEFAULTS,
# 表现成「设置莫名其妙不生效」,而且不报任何错。
print("== 4b. 全局键必须落在实例级 user_id=0 ==")
chk("schedule_times 是全局键", config.is_global_key("schedule_times"))
chk("page_size 是全局键", config.is_global_key("page_size"))
chk("cookie 不是全局键(本人凭证)", not config.is_global_key("cookie"))
chk("slot:* 仍是个人级(每人各自记今天跑过没)",
not config.is_global_key("slot:09:00"))
chk("普通账号只被允许写 cookie/user_agent",
config.writable_by("cookie", False) and config.writable_by("user_agent", False)
and not config.writable_by("schedule_times", False)
and not config.writable_by("page_size", False))
with app.test_client() as cli:
login(cli, ADMIN)
same = db.get_setting(conn, "schedule_times", "", 0)
st, _ = page(cli, "/api/settings", method="POST",
json={"schedule_times": same or "09:00"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("管理员写 schedule_times=200", st == 200, "status=%s" % st)
chk("只存在实例级那一份",
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id=0"
" AND key='schedule_times'").fetchone()[0] == 1)
chk("管理员名下不留个人级副本(否则别人读不到)",
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id<>0"
" AND key='schedule_times'").fetchone()[0] == 0)
# /api/status 是大屏与前端判断角色用的接口,必须真的能开且角色正确
# (它曾经因为改字段时引用了未定义的变量而 500,两层测试都没覆盖到)
st, sj = page(cli, "/api/status")
chk("GET /api/status 管理员=200", st == 200, "status=%s" % st)
if st == 200:
j = json.loads(sj)
chk("└ is_admin=True", j.get("is_admin") is True, "%s" % j.get("is_admin"))
chk("└ can_edit_schedule=True", j.get("can_edit_schedule") is True)
chk("└ 凭证只回「有没有 / 多少字符」",
all(k in j for k in ("cookie_set", "cookie_chars", "cookie_broken"))
and "cookie" not in j)
# 收尾自检:实例级配置必须还在(对照开头那份快照)
inst_after = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
chk("跑完整轮 smoke 后,实例级配置一条不少", inst_before <= inst_after,
"丢失=%s" % sorted(inst_before - inst_after))
conn.close()
# ---------------- 5. 历史缺陷防回归 ----------------