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 处问题
这个提交包含在:
2026-09-18 11:13:17 +08:00
父节点 3751dffef9
当前提交 f36149efc3
共修改 29 个文件,包含 1105 行新增和 558 行删除
+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)