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 行删除
+14 -7
查看文件
@@ -586,10 +586,13 @@ def records_export():
finally:
own.close()
# 文件名带账号名:多人导出到同一目录时不会互相覆盖
name = "usage_%s_%s_%s.csv" % (u["username"], frm or "all", to or db.now_str()[:10])
resp = Response(gen(), mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="%s"' % name})
# 文件名带账号名:多人导出到同一目录时不会互相覆盖。
# 名字必须过 safe_filename —— 响应头里拼一个含引号或 CR/LF 的用户名
# 就是响应头注入(username 并不总是注册正则的产物,见 security.safe_filename)。
name = "usage_%s_%s_%s.csv" % (security.safe_filename(u["username"], fallback="u%d" % uid),
frm or "all", to or db.now_str()[:10])
resp = Response(gen(), mimetype="text/csv; charset=utf-8")
resp.headers["Content-Disposition"] = security.content_disposition(name)
# 导出可能很慢,避免 nginx 之类的前置代理先缓冲整个响应体
resp.headers["X-Accel-Buffering"] = "no"
return resp
@@ -650,8 +653,8 @@ def _zip_stream(buf, filename, mimetype="application/zip"):
except Exception: # noqa: BLE001
pass
resp = Response(gen(), mimetype=mimetype,
headers={"Content-Disposition": 'attachment; filename="%s"' % filename})
resp = Response(gen(), mimetype=mimetype)
resp.headers["Content-Disposition"] = security.content_disposition(filename)
resp.headers["X-Accel-Buffering"] = "no"
return resp
@@ -768,7 +771,11 @@ def profile_export():
"导出时间:%s\n程序版本:%s\n"
% (u["username"], db.now_str(), _app_version())).encode("utf-8"))
name = "my-data_%s_%s.zip" % (u["username"], db.now_str()[:10])
# 用户名先收敛再拼名字:老库里可能存在不符合注册正则的账号名,
# 里面若带反斜杠,content_disposition 为了防路径会把它之后的整段截掉,
# 结果文件名退化成 "x_2026-09-18.zip"。先收敛就不会走到那条截断路径。
name = "my-data_%s_%s.zip" % (security.safe_filename(u["username"], fallback="u%d" % uid),
db.now_str()[:10])
db.audit(conn, "export_self", u["username"], "导出个人全部数据(%s)" % name,
_ip(), uid)
return _zip_stream(buf, name)