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 行删除
+21 -2
查看文件
@@ -15,7 +15,7 @@
常用:
python manage.py init 初始化数据库(建表 + 默认配置 + 管理员)
python manage.py serve 启动 Web(0.0.0.0:8848,进程内含调度线程)
python manage.py serve --port 9000 --debug 开发模式(reloader 下调度只启动一份)
python manage.py serve --debug --host 127.0.0.1 开发模式(--debug 只允许绑回环地址)
python manage.py collect 为**所有已启用账号**各跑一次增量采集
python manage.py collect -u alice 只为 alice 采集
python manage.py migrate-csv [文件] 从旧版 CSV 存档导入(默认自动探测路径)
@@ -127,9 +127,18 @@ def cmd_init(args):
def cmd_serve(args):
from workbuddy_portal import create_app
app = create_app(start_scheduler=not args.no_scheduler)
host = args.host or config.DEFAULT_HOST
port = args.port or config.DEFAULT_PORT
# --debug 会打开 Werkzeug 的交互式调试器:**任意 Python 代码执行**。
# 它虽然有 PIN,但 PIN 会打印在控制台、且在某些部署下可由机器信息推导出来,
# 把它绑到 0.0.0.0(默认值就是这个)等于把 RCE 直接挂上公网。
# 这里直接拒绝:调试要么在本机回环地址上做,要么就别开调试器。
if args.debug and host not in ("127.0.0.1", "localhost", "::1"):
_p("[error] --debug 只能绑定回环地址(当前 --host %s)。" % host)
_p(" Werkzeug 调试器可执行任意 Python 代码,绑到对外地址等于开放 RCE。")
_p(" 本机调试请用:python manage.py serve --debug --host 127.0.0.1")
return 2
app = create_app(start_scheduler=not args.no_scheduler)
if args.debug:
app.run(host=host, port=port, debug=True)
return
@@ -420,6 +429,13 @@ def cmd_passwd(args):
if not pwd:
_p("[error] 新账号必须给出密码")
return 2
# 建号必须走与注册页同一套用户名校验。绕开它的后果不是「名字不好看」:
# 导出文件名与下载响应头都直接拼账号名,一个带 `/`、`..`、引号或 CR/LF
# 的名字会变成路径穿越 / 响应头注入。这里从根上堵掉。
problem = security.username_problem(args.user)
if problem:
_p("[error] 用户名不合规:%s" % problem)
return 2
conn.execute(
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
" created_at) VALUES(?,?,?,?,'active',?)",
@@ -705,6 +721,9 @@ def main():
if not getattr(args, "func", None):
ap.print_help()
return 0
# 先收紧 umask 再建目录:这样 SQLite 库、-wal/-shm、导出 CSV、备份 zip
# 从「被创建的那一刻」就是 owner-only,不必逐个事后 chmod。
config.harden_process()
config.ensure_dirs()
return args.func(args) or 0