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 行删除
+29 -6
查看文件
@@ -20,7 +20,9 @@
* 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写,
任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。
"""
import logging
import os
import secrets
from datetime import datetime
from flask import Blueprint, jsonify, request, send_file, session
@@ -29,6 +31,7 @@ from .. import backup, collect, config, db, query, scheduler, security
from ..security import admin_required, current_user, is_admin, login_required
bp = Blueprint("api", __name__, url_prefix="/api")
log = logging.getLogger("wb.api")
def _uid():
@@ -36,6 +39,23 @@ def _uid():
return u["id"] if u else 0
def _internal(e, where):
"""内部异常的**唯一出口**:细节只进日志,响应只给一个可对账的事件号。
以前这里是 `jsonify({"message": str(e)})` —— 把原始异常文本原样回给客户端。
那等于把服务端内部结构送给调用方:绝对路径(`/app/workbuddy_portal/...`)、
SQL 语句、`sqlite3` 的错误、备份目录位置都可能出现在里面。
对一个对外开放的站点来说,这些正好是踩点阶段最想要的信息。
现在改成:完整堆栈写进服务端日志(带事件号),客户端只拿到事件号 ——
既不影响「出事了去查日志」的运维动作,也不泄露任何内部细节。
"""
eid = secrets.token_hex(4)
log.exception("[%s] %s 处理失败", eid, where)
return jsonify({"ok": False, "error": "internal",
"message": "服务器内部错误,请稍后重试"
"(事件号 %s,管理员可在「日志管理」中检索)" % eid}), 500
def _ip():
"""客户端地址。一律走 security.client_ip() —— 见那里的注释:
直接取 X-Forwarded-For 第 0 段会让「来源」变成请求方自己填的字符串。"""
@@ -87,7 +107,9 @@ def _int(name, default, lo=1, hi=2000):
@login_required
def api_manifest():
u = current_user()
m = query.manifest(db.get_db(), u["id"])
# 数据源清单(表名 / 库文件)属于内部实现细节,只给管理员;
# 普通账号看自己的数据,没有理由知道正本落在哪个文件、叫什么表。
m = query.manifest(db.get_db(), u["id"], sources=u["is_admin"])
# 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文,
# 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。
# 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。
@@ -101,7 +123,8 @@ def api_bundle():
"""大屏页一次拿齐:全量 daily + 窗口 dims/top/records。"""
frm, to = _win()
return jsonify(query.bundle(db.get_db(), _uid(), frm, to,
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000),
sources=is_admin()))
@bp.get("/summary")
@@ -319,7 +342,7 @@ def api_collect():
return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api",
"message": str(e)}), code
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
return _internal(e, "POST /api/collect uid=%s" % uid)
db.audit(conn, "collect", u["username"], r["message"], _ip(), uid)
return jsonify({"ok": True, "result": r})
@@ -407,7 +430,7 @@ def api_maintenance(action):
else:
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
return _internal(e, "POST /api/maintenance/%s uid=%s" % (action, uid))
db.audit(conn, "maintenance:" + action, u["username"], msg, _ip(), uid)
return jsonify({"ok": True, "message": msg})
@@ -449,7 +472,7 @@ def api_backup_create():
except backup.BackupError as e:
return _backup_error(e)
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
return _internal(e, "POST /api/backups")
removed = backup.prune(conn, actor=u["username"])
if removed:
r["message"] += ";按保留份数清理了 %d 份旧备份" % len(removed)
@@ -495,7 +518,7 @@ def api_backup_restore(filename):
except backup.BackupError as e:
return _backup_error(e)
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
return _internal(e, "POST /api/backups/restore")
return jsonify(r)