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 处问题
这个提交包含在:
+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)
|
||||
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户