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