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 行删除
+95 -1
查看文件
@@ -31,6 +31,7 @@ import re
import secrets
import sqlite3
import time
import urllib.parse
from flask import (current_app, flash, g, jsonify, redirect, render_template,
request, session, url_for)
@@ -253,6 +254,67 @@ def action_wait_left(key):
return max(0, int((_actions.get(key) or 0) - time.time()))
# ---------------- 通用读接口限速(对外部署的刹车) ----------------
# 登录有 IP 锁定、注册有配额 + 验证码、采集有最小间隔 —— 但**读接口没有刹车**。
# 对外提供服务后,一个注册账号循环调 /api/bundle(它要算全量逐日聚合,还会
# 下发最多 2 万条明细)就能持续吃掉 CPU 与出口带宽;这不需要任何漏洞,
# 只要把浏览器 F12 里的那个请求放进 for 循环。
# 所以这里按「账号(未登录时按来源 IP)」加一个滑动窗口上限。
# 阈值刻意给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到它。
_API_RATE_MAX = 240 # 每窗口允许的请求数
_API_RATE_WINDOW = 60 # 窗口长度(秒)
_API_HITS_MAX_KEYS = 8192 # 字典上限,防止海量来源把内存撑爆
_api_hits = {} # key -> [count, window_started_at]
def api_rate_ok(key):
"""返回 (是否放行, 还需等待秒数)。"""
now = time.time()
if len(_api_hits) > _API_HITS_MAX_KEYS:
_api_hits.clear()
cur = _api_hits.get(key)
if cur is None or now - cur[1] > _API_RATE_WINDOW:
_api_hits[key] = [1, now]
return True, 0
cur[0] += 1
if cur[0] > _API_RATE_MAX:
return False, int(_API_RATE_WINDOW - (now - cur[1])) + 1
return True, 0
def api_rate_reset():
"""清空读接口计数(测试与排障用)。"""
_api_hits.clear()
# ---------------- 下载文件名 / 响应头 ----------------
# 导出文件名里含用户名,而用户名**并不总是**注册接口那条正则的产物:
# `manage.py passwd` 建号时不做校验,老库升级上来的名字也可能带各种字符。
# 一旦名字里有引号或 CR/LF,直接拼进响应头就是**响应头注入**(响应拆分):
# 引号之后的内容会被下游解析成新的头。所以这里一律先收敛。
_UNSAFE_FN = re.compile(r"[^A-Za-z0-9._-]+")
def safe_filename(name, fallback="download", maxlen=64):
"""把任意字符串收敛成可安全放进头部 / 文件系统的 ASCII 名。"""
s = _UNSAFE_FN.sub("_", str(name or "")).replace("..", "_").strip("._-")
s = s[:maxlen].strip("._-")
return s or fallback
def content_disposition(filename, fallback="download"):
"""构造安全的 Content-Disposition。
两段都给:
* `filename=` —— 纯 ASCII 收敛名,任何客户端都认,且不可能含引号/换行
* `filename*=` —— RFC 5987 的 UTF-8 原名,让中文名在浏览器里仍然好看
只用后者会有老客户端兼容问题,只用前者会把中文名变成一串下划线。
"""
raw = str(filename or "").replace("\\", "/").split("/")[-1].strip() or fallback
return 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (
safe_filename(raw, fallback=fallback), urllib.parse.quote(raw, safe=""))
# ---------------- 口令 / 用户名策略 ----------------
_USERNAME_RE = re.compile(config.USERNAME_RE)
@@ -313,6 +375,20 @@ _check_password = password_problem
# ---------------- 登录 ----------------
# 口令校验的**哑哈希**:用户名不存在时也走一次同代价的 PBKDF2。
# 不补这一步的话,「账号不存在」会比「口令错误」快一到两个数量级
# (前者根本不做哈希计算),攻击者拿一个秒表就能枚举出哪些用户名真实存在。
# 对外开放时用户名枚举通常是撞库的第一步,所以两条路径的耗时必须对齐。
_DUMMY_HASH = None
def _dummy_verify(password):
global _DUMMY_HASH
if _DUMMY_HASH is None:
_DUMMY_HASH = hash_password(secrets.token_urlsafe(16))
verify_password(_DUMMY_HASH, password)
def login_ok(conn, username, password):
"""校验口令。返回 (user_row, error_message)。
@@ -321,7 +397,10 @@ def login_ok(conn, username, password):
"""
username = (username or "").strip()
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if row is None or not verify_password(row["password_hash"], password):
if row is None:
_dummy_verify(password)
return None, "用户名或密码不正确"
if not verify_password(row["password_hash"], password):
return None, "用户名或密码不正确"
if (row["status"] or "active") != "active":
return None, "该账号已被停用,请联系管理员"
@@ -573,6 +652,11 @@ def apply_security_headers(resp):
resp.headers.setdefault("Referrer-Policy", "same-origin")
resp.headers.setdefault("Content-Security-Policy", CSP)
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
# 本项目不需要任何浏览器外设能力,显式关掉:缩小「被内嵌页面 / 被第三方
# 脚本滥用」时的可用面,同时也是对外部署时安全扫描的基本要求。
resp.headers.setdefault(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=(), payment=(), usb=()")
# HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它,
# 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。
# 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。
@@ -615,6 +699,16 @@ def init_app(app):
@app.before_request
def _guard():
g.wb_t0 = time.time()
# 读接口限速放在最前面:被限流的请求不该再走后面那些更贵的事情
# (HTTPS 判断、CSRF、会话校验)。
# 键用 session 里的 uid(不做回查),未登录时退化成来源 IP ——
# 这里只是限速,不承担鉴权职责,所以不需要一个「可信」的身份。
if request.path.startswith("/api/"):
who = session.get("uid") or ("ip:" + client_ip())
ok, wait = api_rate_ok("api:%s" % who)
if not ok:
return jsonify({"ok": False, "error": "rate_limited",
"message": "请求过于频繁,请 %d 秒后再试" % wait}), 429
if needs_https_redirect():
url = request.url.replace("http://", "https://", 1)
return redirect(url, code=301)