fix(security): 代码审查安全加固 — 三批次修复(6H/13M/12L)

第一批(快速修复):
- H6: main.py --debug 默认值 True→False,防止 Werkzeug RCE
- M2+M3: /search 增加 validate_query + top_k 范围限制(1-50)
- M4: context_count 范围限制(0-10) + 异常捕获
- L3: assert → raise RuntimeError(生产环境 API Key 检查)
- H1: SSE 错误事件移除 traceback 字段

第二批(安全加固):
- H2+H3: 文档接口路径遍历 realpath 校验 + 文件类型/大小限制
- H4+H5: 批量上传文件大小检查
- M6: LIKE 查询通配符转义
- M1: 37 处 str(e) 异常信息统一脱敏(6 文件)
- M5: CORS 生产环境限制来源
- M7: SESSION_MANAGER None 保护(503)
- M11: subprocess 参数注入防护(白名单 + -- 分隔符)

第三批(架构改进):
- M8+M9: 提取 JSON 解析共享工具(extract_json_object/list)
- M10: Prompt 注入检测防御(prompt_guard.py)
- M12: 解析器文件大小限制(Excel 50MB/TXT 20MB/PDF 100MB)
- M13: 全局单例竞态条件双重检查锁定(engine/bm25/intent_analyzer)
This commit is contained in:
lacerate551
2026-06-05 15:26:32 +08:00
parent a7206377cc
commit 90b915232a
19 changed files with 436 additions and 102 deletions

View File

@@ -96,6 +96,24 @@ def safe_filename(filename: str) -> str:
return safe_name
def _validate_doc_path(doc_path: str, documents_path: str) -> str:
"""
校验文档路径是否在允许目录内,防止路径遍历攻击。
Returns:
解析后的安全绝对路径
Raises:
ValueError: 路径不合法
"""
filepath = os.path.join(documents_path, doc_path)
real_path = os.path.realpath(filepath)
real_base = os.path.realpath(documents_path)
if not real_path.startswith(real_base + os.sep) and real_path != real_base:
raise ValueError("非法路径")
return real_path
# 延迟初始化缓存
_kb_manager = None
_kb_checked = False
@@ -160,6 +178,10 @@ def serve_document_file(doc_path: str) -> Tuple[Any, int]:
from flask import send_from_directory
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
@@ -329,7 +351,8 @@ def upload_document() -> Tuple[Any, int]:
sync_service.process_change(change)
sync_status = "已保存并添加到向量库"
except Exception as e:
sync_status = f"已保存,向量化失败: {str(e)}"
logger.warning(f"向量化失败: {e}")
sync_status = "已保存,向量化失败"
return success_response(
data={
@@ -404,6 +427,18 @@ def batch_upload_documents() -> Tuple[Any, int]:
})
continue
# 文件大小校验
file.seek(0, 2)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
results.append({
"filename": file.filename,
"status": "error",
"message": f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB"
})
continue
try:
original_filename = file.filename
ext = os.path.splitext(original_filename)[1].lower()
@@ -464,10 +499,11 @@ def batch_upload_documents() -> Tuple[Any, int]:
"replaced": replaced
})
except Exception as e:
logger.error(f"上传处理异常: {e}")
results.append({
"filename": file.filename,
"status": "error",
"message": str(e)
"message": "上传处理失败"
})
return success_response(
@@ -644,12 +680,27 @@ def update_document(doc_path: str) -> Tuple[Any, int]:
if file.filename == '':
return jsonify({"error": "没有选择文件"}), 400
# 文件类型校验
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return jsonify({"error": f"不支持的文件类型: {ext}"}), 400
# 文件大小校验
file.seek(0, 2) # 跳到文件末尾获取大小
file_size = file.tell()
file.seek(0) # 回到文件开头
if file_size > MAX_FILE_SIZE:
return jsonify({"error": f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB"}), 400
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
@@ -711,7 +762,10 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
# 目录名即向量库名
collection = subdir
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
@@ -730,7 +784,8 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
})
except Exception as e:
return jsonify({"error": f"删除失败: {str(e)}"}), 500
logger.error(f"删除文档异常: {e}")
return jsonify({"error": "删除失败,请稍后重试"}), 500
@document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET'])
@@ -817,7 +872,10 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
# 查询参数
chunk_index_str = request.args.get('chunk_index')
context_count = int(request.args.get('context', 2))
try:
context_count = max(0, min(int(request.args.get('context', 2)), 10))
except (ValueError, TypeError):
context_count = 2
# 获取所有切片
all_chunks = kb_manager.get_document_chunks(collection, filename)
@@ -1082,5 +1140,5 @@ def delete_chunks_by_source() -> Tuple[Any, int]:
"message": f"已删除 {deleted_count} 个切片"
})
except Exception as e:
logger.error(f"批量删除切片失败: {e}")
return jsonify({"error": str(e)}), 500
logger.error(f"操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500