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。
这个提交包含在:
+93
-3
@@ -5,18 +5,23 @@
|
||||
|
||||
"""端到端验收:对**运行中的**服务发真实 HTTP 请求,走完整登录/CSRF/API 链路。
|
||||
|
||||
与 tests 里用 Flask test_client 的冒烟测试互补——这里验证的是「真的起起来了、
|
||||
真的能登录、真的能取到数」,适合部署到局域网后随手跑一遍。
|
||||
与 tools/smoke.py 的分工:smoke 用 Flask test_client 直接渲染模板、不发网络请求;
|
||||
本脚本确认真的是「起起来了、能登录、能取到数」,适合部署到局域网后随手跑一遍。
|
||||
|
||||
用法:
|
||||
python tools/check_live.py # 默认 http://127.0.0.1:8848
|
||||
python tools/check_live.py --base http://192.168.1.50:8848 # 换成你的部署主机
|
||||
python tools/check_live.py -u admin -p 你的密码
|
||||
python tools/check_live.py --as alice:她的密码 # 额外跑一遍**普通账号**的越权面
|
||||
python tools/check_live.py --from 2026-09-08 --to 2026-09-14
|
||||
|
||||
`--as` 那一节会真的发越权请求(改调度 / 改采集参数 / 读日志),
|
||||
期望全部被拒;不会创建或删除任何账号,所以请自己先准备一个普通账号。
|
||||
|
||||
退出码:0 全通过;1 有失败项(会打印失败清单)。
|
||||
|
||||
注意:脚本会读取窗口数据但**不写库**(不触发采集、不改配置),可安全反复运行。
|
||||
注意:脚本会读窗口数据、会走登录(登录本身会更新 last_login_at),
|
||||
但**不触发采集、不改任何配置**,可安全反复运行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -313,6 +318,10 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
||||
chk("settings 回传实例级键清单", isinstance(stj.get("_globalKeys"), list)
|
||||
and bool(stj.get("_globalKeys")), "%s" % stj.get("_globalKeys"))
|
||||
chk("settings 标明能否改实例级配置", stj.get("_canEditGlobal") is True)
|
||||
chk("settings 回传个人可写键清单(应为 cookie/user_agent)",
|
||||
set(stj.get("_userKeys") or []) == {"cookie", "user_agent"},
|
||||
"%s" % stj.get("_userKeys"))
|
||||
chk("settings 标明角色", stj.get("_role") == "admin", "%s" % stj.get("_role"))
|
||||
chk("配置页 HTML 不含 cookie 明文", "eyJ" not in L.get("/config")[1])
|
||||
# 密文形态:v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>,恰好用正则判定,
|
||||
# 免得把版本号 "v1.2.0" 当成泄漏(这两者前缀撞车)
|
||||
@@ -417,6 +426,74 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
||||
"status=%s" % code)
|
||||
|
||||
|
||||
def run_nonadmin(base: str, timeout: int, db_path: str, user: str, pwd: str) -> None:
|
||||
"""普通账号的越权面(真实 HTTP 链路,--as 才跑)。
|
||||
|
||||
规则只有一条:普通账号**只能维护本人凭证**,其余配置 / 日志 / 用户管理
|
||||
全部不可达。期望值是 403(页面)与 400(写配置)—— 不是「看得到但改不了」,
|
||||
更不是「写进去但只对自己生效」。
|
||||
"""
|
||||
print("== 12. 普通账号越权面(--as %s) ==" % user)
|
||||
L = Live(base, timeout, db_path)
|
||||
st, _, _, _ = L.login(user, pwd)
|
||||
chk("普通账号登录成功", st in (200, 302), "status=%s" % st)
|
||||
st, html = L.get("/")
|
||||
if st != 200 or "概览" not in html:
|
||||
chk("普通账号登录后能看到概览", False, "status=%s(后续断言已跳过)" % st)
|
||||
return
|
||||
chk("普通账号登录后能看到概览", True)
|
||||
chk("导航不出现「日志管理」", "日志管理" not in html)
|
||||
chk("导航不出现「用户管理」", "用户管理" not in html)
|
||||
|
||||
# 可达页面(都只渲染本人数据)
|
||||
for p, kw in [("/records", "记录"), ("/tasks", "任务"),
|
||||
("/config", "配置"), ("/profile", "个人")]:
|
||||
st, body = L.get(p)
|
||||
chk("GET %-9s 普通账号=200" % p, st == 200 and kw in body, "status=%s" % st)
|
||||
st, _ = L.get("/dashboard")
|
||||
chk("GET /dashboard 普通账号=200", st == 200, "status=%s" % st)
|
||||
|
||||
# 不可达:日志与用户管理
|
||||
for p in ("/logs", "/logs/tail?lines=10", "/users", "/api/users"):
|
||||
st, _ = L.get(p)
|
||||
chk("GET %-21s 普通账号=403" % p, st == 403, "status=%s" % st)
|
||||
|
||||
# 角色标记:页面之外还有静态页(大屏)与前端要靠它决定显隐
|
||||
stj = L.jget("/api/settings")
|
||||
chk("/api/settings _role=user", stj.get("_role") == "user", "%s" % stj.get("_role"))
|
||||
chk("/api/settings _canEditGlobal=False", stj.get("_canEditGlobal") is False)
|
||||
mf = L.jget("/api/manifest")
|
||||
chk("/api/manifest role=user(大屏据此隐掉日志入口)",
|
||||
mf.get("role") == "user", "%s" % mf.get("role"))
|
||||
sta = L.jget("/api/status")
|
||||
chk("/api/status is_admin=False", sta.get("is_admin") is False, "%s" % sta.get("is_admin"))
|
||||
chk("/api/status can_edit_schedule=False", sta.get("can_edit_schedule") is False)
|
||||
|
||||
# 越权写:调度 / 采集参数 / 实例级键 -> 400
|
||||
csrf = L.form_csrf("/config")
|
||||
chk("拿到普通账号自己的 CSRF", bool(csrf))
|
||||
for key, val in (("schedule_times", "23:59"), ("schedule_enabled", "0"),
|
||||
("page_size", "1000"), ("ssl_verify", "0"),
|
||||
("api_base", "http://evil.invalid"), ("allow_register", "0")):
|
||||
st, body = L.post("/api/settings", {key: val}, csrf=csrf, as_json=True)
|
||||
chk("越权 POST %-16s =400" % key, st == 400, "status=%s" % st)
|
||||
chk(" └ 且点名 %s" % key, key in body)
|
||||
|
||||
# 本人 UA 必须写得进去;写回原值,不给对方留副作用
|
||||
cur_ua = str(stj.get("user_agent") or "")
|
||||
st, body = L.post("/api/settings", {"user_agent": cur_ua}, csrf=csrf, as_json=True)
|
||||
chk("本人 user_agent 可写=200", st == 200, "status=%s %s" % (st, body[:100]))
|
||||
|
||||
# 页面只给凭证表单,采集参数与调度都渲染成只读
|
||||
st, cf = L.get("/config")
|
||||
chk("配置页有凭证表单", 'id="formCred"' in cf)
|
||||
chk("配置页无采集参数表单", 'id="formCollect"' not in cf)
|
||||
chk("配置页无实例级设置表单", 'id="formGlobal"' not in cf)
|
||||
st, tk = L.get("/tasks")
|
||||
chk("任务页调度只读(没有保存按钮)", "保存调度配置" not in tk)
|
||||
chk("任务页标注调度仅管理员可改", "仅管理员可改" in tk)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="对运行中的用量门户做端到端验收")
|
||||
ap.add_argument("--base", default="http://127.0.0.1:8848", help="服务地址")
|
||||
@@ -429,12 +506,25 @@ def main() -> int:
|
||||
"验证码策略为 always 时用它取答案以完成自动登录;"
|
||||
"指向不存在的文件则跳过需要验证码的登录")
|
||||
ap.add_argument("--timeout", type=int, default=20)
|
||||
ap.add_argument("--as", dest="as_user", default=None, metavar="USER:PASS",
|
||||
help="额外用一个**普通账号**跑一遍越权验收(第 12 节)。"
|
||||
"不会创建/删除账号,请自己先备好一个普通账号")
|
||||
a = ap.parse_args()
|
||||
|
||||
db_path = a.db or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "usage.sqlite")
|
||||
print("目标:%s 窗口:%s ~ %s\n验证码答案源:%s\n" % (a.base, a.frm, a.to, db_path))
|
||||
run(Live(a.base, a.timeout, db_path), a.user, a.password, a.frm, a.to)
|
||||
if a.as_user:
|
||||
if ":" not in a.as_user:
|
||||
print(" [FAIL] --as 需要写成 用户名:密码")
|
||||
FAILS.append("--as 参数格式")
|
||||
else:
|
||||
nu, np_ = a.as_user.split(":", 1)
|
||||
try:
|
||||
run_nonadmin(a.base, a.timeout, db_path, nu, np_)
|
||||
except Exception as e: # noqa: BLE001
|
||||
chk("第 12 节执行未抛异常", False, "%s: %s" % (type(e).__name__, e))
|
||||
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
|
||||
if FAILS:
|
||||
print("失败项:%s" % "、".join(FAILS))
|
||||
|
||||
在新工单中引用
屏蔽一个用户