feat(安全): 对外暴露面加固 + 界面去 AI 化(v1.5.0)
界面(去 AI 味): - 大屏页清除 114 处生成器残留属性 data-page-node-id - 视觉系统改回工程控制台风格:去 radial/linear-gradient、去辉光、 去标题前彩色装饰条,改为中性灰阶 + 单一蓝色强调色;KPI 色条改状态点 - 精简各页说教式长提示;修掉 profile.html 泄漏到页面上的 Markdown 星号 - 删除登录页过时的「默认账号 admin / admin123」提示(1.4.0 起已无默认口令) 安全与隐私(按「将会被公网访问」收口): - 内部异常只回 8 位事件号,完整堆栈进服务端日志(web/api.py::_internal) - 导出文件名收敛:防响应头注入与路径穿越;manage.py passwd 补用户名校验 - 登录对不存在的账号也走一次哑哈希,抹平用户名枚举的时序差异 - /api/* 读接口限速 240 次 / 60 秒 / 账号(挡住循环调 /api/bundle) - 进程 umask 0077 + 目录 0700 / 文件 0600:对话正文与主密钥的落盘权限 - 表名与库文件路径只对管理员下发;大屏页所有数据插值转义 - --debug 只允许绑定回环地址;新增 Permissions-Policy 与 413 处理器 文档: - DEPLOYMENT 新增第十三节「安全与隐私基线」;迁移表补 1.4.0 → 1.5.0 行 - SECURITY 更新支持范围、新增「信息泄漏收敛」小节与上线检查项 - .codebuddy/ 加入 .gitignore(助手工作记忆不进仓库) 版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4) 验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题
这个提交包含在:
@@ -27,6 +27,10 @@ import sys
|
||||
SKIP_DIRS = {
|
||||
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build",
|
||||
".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".idea", ".vscode",
|
||||
# AI 助手的工作记忆与技能缓存:是开发过程产物,不是项目文档。
|
||||
# 它已在 .gitignore 里,这里一并跳过,免得自检报告被一堆无关 .md 淹没
|
||||
# (而且那些笔记里的链接是给助手看的,不该按项目文档的规则去校验)。
|
||||
".codebuddy",
|
||||
}
|
||||
|
||||
# markdown 链接:[文本](目标)
|
||||
|
||||
+117
-1
@@ -17,6 +17,7 @@
|
||||
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / **普通账号的越权面**(4. 与 4b.)
|
||||
5. CSV 导出可被标准 csv 解析、列数一致
|
||||
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
|
||||
9. 对外暴露面:下载响应头收敛 / 内部异常不外泄 / 读接口限速 / 安全响应头
|
||||
|
||||
权限模型(改断言前先读这一行):
|
||||
普通账号**只能写** `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
|
||||
@@ -487,7 +488,7 @@ def run() -> None:
|
||||
chk("⑨ 配置页有凭证表单", 'id="formCred"' in cf)
|
||||
chk("⑨ 配置页无采集参数表单", 'id="formCollect"' not in cf)
|
||||
chk("⑨ 配置页无实例级设置表单", 'id="formGlobal"' not in cf)
|
||||
chk("⑨ 配置页说明范围", "唯一可以修改" in cf)
|
||||
chk("⑨ 配置页说明范围", "只有这一块可改" in cf)
|
||||
# ⑩ /api/status 的角色字段(大屏与前端靠它显隐管理员入口)
|
||||
st, sj = page(cli, "/api/status")
|
||||
chk("⑩ GET /api/status 普通账号=200", st == 200, "status=%s" % st)
|
||||
@@ -774,6 +775,121 @@ def run() -> None:
|
||||
bconn.commit()
|
||||
bconn.close()
|
||||
|
||||
# ---------------- 9. 对外暴露面(安全与隐私基线) ----------------
|
||||
# 这一节盯的是「直接挂到公网」时最容易漏掉的三类东西:
|
||||
# ① 下载文件名 / 响应头注入 —— 用户名并不总是注册正则的产物
|
||||
# (manage.py passwd 建号、老库升级上来的名字都可能带引号或 CR/LF);
|
||||
# ② 内部异常直出 —— 原始异常文本会带绝对路径与 SQL 片段,是踩点的现成材料;
|
||||
# ③ 读接口没有刹车 —— 一个注册账号循环调 /api/bundle 就能持续吃满 CPU。
|
||||
print("== 9. 对外暴露面:响应头收敛 / 异常不外泄 / 读接口限速 ==")
|
||||
|
||||
for bad in ('a"b.csv', "a\r\nX-Evil: 1", "../../etc/passwd", "a\\b.zip", "..",
|
||||
".", " ", "", "中文\n名.csv"):
|
||||
got = security.safe_filename(bad, fallback="download")
|
||||
chk("⑨ safe_filename 收敛 %r" % bad,
|
||||
bool(got) and not any(c in got for c in '"\\\r\n/') and ".." not in got,
|
||||
"-> %r" % got)
|
||||
|
||||
cd = security.content_disposition('a"b\r\nX-Evil: 1.zip')
|
||||
quoted = cd.split('filename="', 1)[1].split('"', 1)[0] if 'filename="' in cd else "?"
|
||||
chk("⑨ content_disposition 的 ASCII 名段无引号/换行/路径",
|
||||
not any(c in quoted for c in '"\\\r\n/'), "-> %r" % cd[:80])
|
||||
chk("⑨ content_disposition 带 RFC 5987 原名段", "filename*=UTF-8''" in cd)
|
||||
|
||||
# 端到端:直接造一个「非法用户名」的账号,看真实导出响应头是否仍然干净。
|
||||
# 走 SQL 插入而不是接口,正是为了模拟「老库里已经有这种名字」的现状。
|
||||
api_src = open(os.path.join(BASE, "workbuddy_portal", "web", "api.py"),
|
||||
encoding="utf-8").read()
|
||||
evil = 'smoke_ev"%s\\x' % _rand(4)
|
||||
nconn = db.connect()
|
||||
try:
|
||||
nconn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
|
||||
"status,created_at) VALUES(?,?,'越权探针',0,'active',?)",
|
||||
(evil, security.hash_password("Smoke-Pass1"), db.now_str()))
|
||||
eid = nconn.execute("SELECT id FROM users WHERE username=?", (evil,)).fetchone()["id"]
|
||||
with app.test_client() as ce:
|
||||
login(ce, eid)
|
||||
for path, what in (("/records/export", "按明细导出"), ("/profile/export", "个人数据包")):
|
||||
h = ce.get(path).headers.get("Content-Disposition", "")
|
||||
chk("⑨ %s 响应头已收敛" % what,
|
||||
bool(h) and "\r" not in h and "\n" not in h and 'filename="' in h,
|
||||
"-> %r" % h[:80])
|
||||
finally:
|
||||
nconn.execute("DELETE FROM users WHERE username=?", (evil,))
|
||||
nconn.commit()
|
||||
nconn.close()
|
||||
|
||||
# ② 接口层的兜底 catch 不能再把原始异常文本回给客户端。
|
||||
# 注意断言的是「兜底分支」而不是「所有 str(e)」:BadParam / Busy /
|
||||
# NotReady / ApiError / BackupError 这些都是**面向用户写的**业务异常,
|
||||
# 文案本身就是要给用户看的,把它们也禁掉是过度收紧。
|
||||
# 真正会带出绝对路径与 SQL 的只有 `except Exception` 那一条路。
|
||||
generic = re.findall(r"except Exception as e:[^\n]*\n([^\n]*)", api_src)
|
||||
chk("⑨ 每个 except Exception 都走 _internal(不回原始异常文本)",
|
||||
bool(generic) and all("_internal(" in g for g in generic),
|
||||
"n=%d" % len(generic))
|
||||
|
||||
# ③ 读接口限速:把阈值临时调小,验机制本身(真阈值在 security._API_RATE_MAX)
|
||||
chk("⑨ 读接口阈值给得足够宽松(不会误伤正常翻页)",
|
||||
security._API_RATE_MAX >= 60, "max=%d/60s" % security._API_RATE_MAX)
|
||||
old_max = security._API_RATE_MAX
|
||||
try:
|
||||
security._API_RATE_MAX = 5
|
||||
security.api_rate_reset()
|
||||
with app.test_client() as cr:
|
||||
login(cr, ADMIN)
|
||||
codes = [page(cr, "/api/status")[0] for _ in range(6)]
|
||||
chk("⑨ 读接口限速:前 5 次放行", codes[:5] == [200] * 5, "codes=%s" % codes)
|
||||
chk("⑨ 读接口限速:第 6 次 429", codes[5] == 429, "codes=%s" % codes)
|
||||
finally:
|
||||
security._API_RATE_MAX = old_max
|
||||
security.api_rate_reset()
|
||||
|
||||
# ④ 安全响应头:对外部署时这些是扫描器与浏览器共同依赖的基线
|
||||
with app.test_client() as ch_:
|
||||
h = ch_.get("/login").headers
|
||||
for name in ("Content-Security-Policy", "Permissions-Policy",
|
||||
"X-Content-Type-Options", "X-Frame-Options", "Referrer-Policy"):
|
||||
chk("⑨ 响应头 %s 已下发" % name, name in h)
|
||||
chk("⑨ 会话 Cookie 标了 HttpOnly", "HttpOnly" in h.get("Set-Cookie", ""))
|
||||
|
||||
# ⑥ 内部实现细节不下发给普通账号:表名、库文件路径这些是踩点材料,
|
||||
# 普通账号看自己的数据,没有任何理由知道正本落在哪、叫什么表。
|
||||
probe = "smoke_p_%s" % _rand()
|
||||
nconn2 = db.connect()
|
||||
try:
|
||||
nconn2.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
|
||||
"status,created_at) VALUES(?,?,'隐私探针',0,'active',?)",
|
||||
(probe, security.hash_password("Smoke-Pass1"), db.now_str()))
|
||||
pid = nconn2.execute("SELECT id FROM users WHERE username=?",
|
||||
(probe,)).fetchone()["id"]
|
||||
with app.test_client() as cu:
|
||||
login(cu, pid)
|
||||
ju = json.loads(page(cu, "/api/manifest")[1])
|
||||
chk("⑨ 普通账号拿不到「数据源」清单", ju.get("sources") == [],
|
||||
"sources=%s" % ju.get("sources"))
|
||||
chk("⑨ 普通账号拿不到库文件路径", not ju.get("archive"),
|
||||
"archive=%r" % ju.get("archive"))
|
||||
chk("⑨ 但仍能拿到自己的存档总量(不是把整块砍掉)",
|
||||
ju.get("totals", {}).get("records") == 0, "records=%s"
|
||||
% ju.get("totals", {}).get("records"))
|
||||
with app.test_client() as ca:
|
||||
login(ca, ADMIN)
|
||||
ja = json.loads(page(ca, "/api/manifest")[1])
|
||||
chk("⑨ 管理员仍能看到「数据源」清单", bool(ja.get("sources")),
|
||||
"n=%d" % len(ja.get("sources") or []))
|
||||
finally:
|
||||
nconn2.execute("DELETE FROM users WHERE username=?", (probe,))
|
||||
nconn2.commit()
|
||||
nconn2.close()
|
||||
|
||||
# ⑤ 大屏页的数据插值必须全部转义(这里只钉住这次修掉的那几处,
|
||||
# 它们是「模型名 / 客户端名来自上游接口」这条链路上最短的几根引线)
|
||||
dash = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "dashboard",
|
||||
"index.html"), encoding="utf-8").read()
|
||||
for probe in ("<b>${p.name}", "<b>${d.name}", "${x.model}</td>", "${x.client}</td>"):
|
||||
chk("⑨ 大屏页已转义 %s" % probe, probe not in dash)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("工程目录:%s\n" % BASE)
|
||||
|
||||
在新工单中引用
屏蔽一个用户