fix(static): 第2轮审计修复12项安全缺陷

修复 P1 路径遍历前缀 bypass、代理错误信息泄露、大文件 OOM、邮箱枚举与 CORS 通配;P2 Range 边界、hash 参数校验、db 连接复用、favicon 端口校验、日志批丢、端口解析异常。CSP/SSL 标记为已知限制。审计文档同步更新,修复率 36% → 88%。
This commit is contained in:
qing committed 2026-08-08 02:21:08 +08:00
1 parent 1ed48a1f48
commit bcec529761
4 files changed
+421 -172

No files matched your search

+122 -62
View File
@@ -49,7 +49,11 @@ apply_security_headers(app)
# 引导配置:confs 连接地址(不依赖 confs 本身)
_confs_host = os.environ.get('CONFS_HOST', '127.0.0.1')
_confs_port = int(os.environ.get('CONFS_PORT', '8010'))
try:
_confs_port = int(os.environ.get('CONFS_PORT', '8010'))
except ValueError:
print('[static] 致命错误: CONFS_PORT 不是合法整数', file=sys.stderr)
sys.exit(1)
# 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段
file_config = {
@@ -181,13 +185,15 @@ def get_media_mime_type(filename):
def safe_path_join(base_dir, user_path):
if not user_path or user_path.strip() == '' or user_path.strip() == '/':
return base_dir
return os.path.realpath(base_dir)
if user_path.startswith('/'):
user_path = user_path[1:]
full_path = os.path.normpath(os.path.join(base_dir, user_path))
base_dir_real = os.path.realpath(base_dir)
full_path_real = os.path.realpath(full_path)
if not full_path_real.startswith(base_dir_real):
# 边界校验:必须等于 base_dir 或以 base_dir + 路径分隔符开头,
# 防止 /app/public_secret 绕过 /app/public 前缀校验(NEW-5)
if full_path_real != base_dir_real and not full_path_real.startswith(base_dir_real + os.sep):
return None
return full_path_real
@@ -271,10 +277,14 @@ def stream_video(file_path):
if range_header:
byte_range = range_header.replace('bytes=', '').split('-')
start = int(byte_range[0]) if byte_range[0] else 0
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
try:
start = int(byte_range[0]) if byte_range[0] else 0
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
except (ValueError, IndexError):
return jsonify({'error': '无效的 Range 请求'}), 400
if start >= file_size:
# 边界校验(P2-1):start/end 必须在 [0, file_size-1] 且 end >= start
if start < 0 or start >= file_size or end >= file_size or end < start:
return jsonify({'error': '请求范围超出文件大小'}), 416
length = end - start + 1
@@ -298,9 +308,18 @@ def stream_video(file_path):
return jsonify({'error': str(e)}), 500
# db 服务 RPC 调用辅助函数(替代直连 SQLite)
# NEW-9:模块级单例 client + 环境变量 + retry_on_refused,避免每次调用新建连接
_db_host = os.environ.get('DB_HOST', '127.0.0.1')
try:
_db_port = int(os.environ.get('DB_PORT', '8005'))
except ValueError:
print('[static] 致命错误: DB_PORT 不是合法整数', file=sys.stderr)
sys.exit(1)
from pymsg import RpcClient as _RpcClient, RpcError as _RpcError
_db_client = _RpcClient(_db_host, _db_port, 'static', timeout=5, retry_on_refused=True)
def _db_call(action, table, where=None, values=None, columns=None):
from pymsg import RpcClient, RpcError
client = RpcClient('127.0.0.1', 8005, 'static', timeout=5)
data = {"action": action, "table": table}
if where is not None:
data["where"] = where
@@ -308,18 +327,54 @@ def _db_call(action, table, where=None, values=None, columns=None):
data["values"] = values
if columns is not None:
data["columns"] = columns
result = client.call('execute', data)
result = _db_client.call('execute', data)
if not result.get('success'):
raise RpcError(f"db execute failed: {result.get('error', 'unknown')}")
raise _RpcError(f"db execute failed: {result.get('error', 'unknown')}")
return result.get('data', [])
# ==================== 速率限制(P1-1:内存滑动窗口,无需外部依赖) ====================
import time
import threading
_rate_limit_lock = threading.Lock()
_rate_limit_store = {} # key -> list of timestamps
def _check_rate_limit(key, max_requests, window_seconds):
"""滑动窗口限速。返回 True 表示允许,False 表示超限。"""
now = time.time()
cutoff = now - window_seconds
with _rate_limit_lock:
timestamps = _rate_limit_store.get(key, [])
# 清理过期记录
timestamps = [t for t in timestamps if t > cutoff]
if len(timestamps) >= max_requests:
_rate_limit_store[key] = timestamps
return False
timestamps.append(now)
_rate_limit_store[key] = timestamps
return True
def _client_ip():
"""获取客户端 IP(gateway 反代场景取 X-Forwarded-For 首段)。"""
xff = request.headers.get('X-Forwarded-For', '')
if xff:
return xff.split(',')[0].strip()
return request.remote_addr or 'unknown'
@app.route('/api/hash', methods=['POST'])
def api_hash():
from flask import request, jsonify
import hashlib
data = request.get_json(force=True)
h = hashlib.sha256(data.get('password','').encode()).hexdigest()
# P1-1:哈希预言机限速,每 IP 每分钟 20 次
if not _check_rate_limit(f"hash:{_client_ip()}", max_requests=20, window_seconds=60):
return jsonify({"error": "rate limit exceeded"}), 429
# NEW-8:移除 force=True,增加类型校验
data = request.get_json(silent=True) or {}
password = data.get('password', '')
if not isinstance(password, str):
return jsonify({"error": "invalid input"}), 400
h = hashlib.sha256(password.encode()).hexdigest()
return jsonify({"hash": h})
@@ -343,16 +398,20 @@ def api_hash():
@app.route('/api/check_user', methods=['POST'])
def api_check_user():
from flask import request, jsonify
data = request.get_json(force=True)
email = data.get('email', '').strip()
if not email:
# P1-1:邮箱枚举防护,每 IP 每分钟 10 次
if not _check_rate_limit(f"check_user:{_client_ip()}", max_requests=10, window_seconds=60):
return jsonify({"success": False, "error": "rate limit exceeded"}), 429
data = request.get_json(silent=True) or {}
email = data.get('email', '')
if not isinstance(email, str) or not email.strip():
return jsonify({"success": False, "error": "missing email"}), 400
email = email.strip()
try:
rows = _db_call('select', 'Users', where={'email': email}, columns=['email'])
return jsonify({"success": True, "found": len(rows) > 0})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
logs.warning("static", "check_user 查询失败", extra={"error": str(e)})
return jsonify({"success": False, "error": "query failed"}), 500
@@ -478,73 +537,66 @@ import urllib.request as _urlreq, json as _json
DNFS_URL = "http://127.0.0.1:8006"
# dnfs 代理路由通用错误响应(NEW-6:不泄露内部异常细节)
_DNFS_PROXY_ERROR = _json.dumps({"error": "upstream unavailable"}).encode()
def _cors(resp):
resp.headers["Access-Control-Allow-Origin"] = "*"
"""dnfs 代理路由 CORS:按白名单回显 Origin,不再通配 *(NEW-3)。"""
origin = request.headers.get("Origin")
if origin and origin in _cors_origins:
resp.headers["Access-Control-Allow-Origin"] = origin
resp.headers["Vary"] = "Origin"
resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
return resp
def _proxy_to_dnfs(route_name):
"""dnfs 代理通用逻辑:转发 JSON + Authorization,返回响应。"""
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers:
h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"):
h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/{route_name}", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
@app.route('/get_notes', methods=['POST', 'OPTIONS'])
def proxy_get_notes():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/get_notes", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
return _proxy_to_dnfs("get_notes")
except Exception as e:
logs.warning("static", "dnfs get_notes 代理失败",
extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
logs.warning("static", "dnfs get_notes 代理失败", extra={"error": str(e)})
return _cors(make_response(_DNFS_PROXY_ERROR, 502, {"Content-Type": "application/json"}))
@app.route('/add_note', methods=['POST', 'OPTIONS'])
def proxy_add_note():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/add_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
return _proxy_to_dnfs("add_note")
except Exception as e:
logs.warning("static", "dnfs add_note 代理失败", extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
return _cors(make_response(_DNFS_PROXY_ERROR, 502, {"Content-Type": "application/json"}))
@app.route('/delete_note', methods=['POST', 'OPTIONS'])
def proxy_delete_note():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/delete_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
return _proxy_to_dnfs("delete_note")
except Exception as e:
logs.warning("static", "dnfs delete_note 代理失败", extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
return _cors(make_response(_DNFS_PROXY_ERROR, 502, {"Content-Type": "application/json"}))
@app.route('/update_note', methods=['POST', 'OPTIONS'])
def proxy_update_note():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/update_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
return _proxy_to_dnfs("update_note")
except Exception as e:
logs.warning("static", "dnfs update_note 代理失败",
extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
logs.warning("static", "dnfs update_note 代理失败", extra={"error": str(e)})
return _cors(make_response(_DNFS_PROXY_ERROR, 502, {"Content-Type": "application/json"}))
# @app.route('/api/proxy_send_code', methods=['POST'])
@@ -620,6 +672,9 @@ def api_favicon():
return jsonify({'error': 'bad url'}), 400
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
return jsonify({'error': 'bad url'}), 400
# NEW-10:校验端口范围,防止端口扫描
if parsed.port is not None and not (1 <= parsed.port <= 65535):
return jsonify({'error': 'bad port'}), 400
if _is_private_host(parsed.hostname):
return jsonify({'error': 'forbidden host'}), 403
@@ -677,9 +732,7 @@ def serve_file(path):
if is_video_file(full_path) or is_audio_file(full_path):
return send_file(full_path, mimetype=get_media_mime_type(full_path))
# 普通文件
with open(full_path, 'rb') as f:
content = f.read()
# 普通文件:使用 send_file 流式传输,避免大文件全量读入内存(NEW-7)
ext = os.path.splitext(full_path)[1].lower()
mime_map = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
@@ -687,7 +740,7 @@ def serve_file(path):
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.txt': 'text/plain',
'.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon'
}
return Response(content, mimetype=mime_map.get(ext, 'application/octet-stream'))
return send_file(full_path, mimetype=mime_map.get(ext, 'application/octet-stream'))
# 目录处理:返回 __view.html,由前端 JS 根据 URL 路径调用 /api/filelist 拉取文件列表
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
@@ -711,7 +764,11 @@ def health_check():
if __name__ == '__main__':
host = file_config['server']['host']
port = int(file_config['server']['port'])
try:
port = int(file_config['server']['port'])
except (ValueError, TypeError):
print('[static] 致命错误: server.port 不是合法整数', file=sys.stderr)
sys.exit(1)
print("\n" + "="*50)
print("📁 文件托管服务器 (极简版)")
@@ -723,8 +780,11 @@ if __name__ == '__main__':
print("\n")
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
# Waitress 不支持 TLS,SSL 场景回退到 Flask 内置服务器
logger.warning("SSL enabled: falling back to Flask built-in server (Waitress has no TLS support)")
# NEW-12(已知限制):Waitress 不支持 TLS,SSL 场景回退到 Flask 内置开发服务器。
# 生产环境应在 waf/gateway 层终止 TLS,不应在此处启用 SSL。
# 若运维启用此处 SSL,将面临单线程、无并发、调试信息泄露等风险。
logger.error("SSL enabled at static layer: using Flask dev server (NOT production-safe). "
"TLS should be terminated at waf/gateway, not here.")
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
else:
from waitress import serve
+7 -5
View File
@@ -120,7 +120,11 @@ class LogClient:
# ── 内部实现 ────────────────────────────────────────────────────────
def _send_batch(self, batch: list):
"""后台线程回调:逐条 INSERT 到 db(与旧版行为一致:网络错误放弃本批剩余)。"""
"""后台线程回调:逐条 INSERT 到 db。
NEW-13:单条失败仅跳过该条,不再因网络异常放弃整批剩余日志。
RpcError 视为可重试的业务错误(如 db 临时拒绝),继续处理下一条。
"""
for entry in batch:
extra_val = entry.get("extra")
try:
@@ -141,11 +145,9 @@ class LogClient:
}
try:
self._client.call("execute", payload, timeout=_TIMEOUT)
except RpcError as e:
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
except Exception as e:
print(f"[log_client] 发送失败(放弃本批剩余): {e}", file=_sys.stderr)
break
print(f"[log_client] INSERT 失败(跳过本条): {e}", file=_sys.stderr)
continue
def _on_error(self, e: Exception, batch: list):
"""异常安全网:handler 外的异常也不会崩溃线程。"""
+3
View File
@@ -23,6 +23,9 @@ def apply_security_headers(app, csp_policy=None):
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
# Content Security Policy
# 注:script-src 含 'unsafe-inline' 因 dnfs 特殊节点"前端代码"功能需要内联脚本。
# NEW-11(已知限制):若发生 HTML 注入,CSP 无法阻止 XSS 执行。
# 后续改进方向:改用 CSP nonce('nonce-{random}'),需同步改造 dnfs 前端注入逻辑。
if csp_policy:
response.headers['Content-Security-Policy'] = csp_policy
else: