security: H5 traceback gate + MED tier (upload sniff, path validation, CSP)
H5 (traceback leak, completing the prior log-level fix): new app/utils/security.py
safe_traceback() logs the full stack server-side and returns it to clients only when
FLASK_DEBUG; all 53 traceback.format_exc() in api/{graph,report,simulation}.py now call
it (import traceback removed).
Upload content sniff: upload_content_ok() magic-byte check — pdf must start %PDF-,
txt/md/markdown rejected if they contain NUL bytes (BOM-prefixed UTF-16/32/8 text
allowed). Wired into the graph.py upload loop so a renamed binary can't pass the
extension whitelist.
Path validation: validate_id() (^[A-Za-z0-9_-]{1,64}$) blocks traversal before every
id->filesystem sink — ProjectManager._get_project_dir, SimulationManager._get_simulation_dir,
ReportManager._get_report_folder + the two Report*Logger __init__, and a new
SimulationRunner._run_dir() that all RUN_STATE_DIR joins route through.
CSP / security headers: CSP <meta> in index.html, vite preview.headers
(X-Frame-Options/nosniff/Referrer-Policy + CSP frame-ancestors), and a Flask
after_request that sets the same headers on API responses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
394638d426
commit
ed840c7a00
|
|
@ -100,7 +100,16 @@ def create_app(config_class=Config):
|
|||
logger = get_logger('mirofish.request')
|
||||
logger.debug(f"响应: {response.status_code}")
|
||||
return response
|
||||
|
||||
|
||||
# 安全响应头(纵深防御):API 响应附带基础安全头。前端 HTML 的 CSP 由 index.html 的
|
||||
# <meta> + vite preview 响应头提供(后端不直接服务 HTML)。
|
||||
@app.after_request
|
||||
def security_headers(response):
|
||||
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
||||
response.headers.setdefault('X-Frame-Options', 'DENY')
|
||||
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
return response
|
||||
|
||||
# 注册蓝图
|
||||
from .api import graph_bp, simulation_bp, report_bp
|
||||
app.register_blueprint(graph_bp, url_prefix='/api/graph')
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"""
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from ..utils.security import safe_traceback, upload_content_ok
|
||||
import threading
|
||||
from flask import request, jsonify
|
||||
|
||||
|
|
@ -182,7 +182,8 @@ def generate_ontology():
|
|||
all_text = ""
|
||||
|
||||
for file in uploaded_files:
|
||||
if file and file.filename and allowed_file(file.filename):
|
||||
# 扩展名白名单 + 魔术字节嗅探(拒绝改名混入的二进制/伪装文件)
|
||||
if file and file.filename and allowed_file(file.filename) and upload_content_ok(file, file.filename):
|
||||
# 保存文件到项目目录
|
||||
file_info = ProjectManager.save_file_to_project(
|
||||
project.project_id,
|
||||
|
|
@ -251,7 +252,7 @@ def generate_ontology():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -495,7 +496,7 @@ def build_graph():
|
|||
except Exception as e:
|
||||
# 更新项目状态为失败
|
||||
build_logger.error(f"[{task_id}] 图谱构建失败: {str(e)}")
|
||||
build_logger.debug(traceback.format_exc())
|
||||
build_logger.debug(safe_traceback())
|
||||
|
||||
project.status = ProjectStatus.FAILED
|
||||
project.error = str(e)
|
||||
|
|
@ -505,7 +506,7 @@ def build_graph():
|
|||
task_id,
|
||||
status=TaskStatus.FAILED,
|
||||
message=t('progress.buildFailed', error=str(e)),
|
||||
error=traceback.format_exc()
|
||||
error=safe_traceback()
|
||||
)
|
||||
|
||||
# 启动后台线程
|
||||
|
|
@ -525,7 +526,7 @@ def build_graph():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -590,7 +591,7 @@ def get_graph_data(graph_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -618,5 +619,5 @@ def delete_graph(graph_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Report API路由
|
|||
"""
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from ..utils.security import safe_traceback
|
||||
import threading
|
||||
from flask import request, jsonify, send_file
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ def generate_report():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -312,7 +312,7 @@ def get_report(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -351,7 +351,7 @@ def get_report_by_simulation(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -391,7 +391,7 @@ def list_reports():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -437,7 +437,7 @@ def download_report(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -463,7 +463,7 @@ def delete_report(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -560,7 +560,7 @@ def chat_with_report_agent():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -603,7 +603,7 @@ def get_report_progress(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -654,7 +654,7 @@ def get_report_sections(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -698,7 +698,7 @@ def get_single_section(report_id: str, section_index: int):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -749,7 +749,7 @@ def check_report_status(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -810,7 +810,7 @@ def get_agent_log(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -844,7 +844,7 @@ def stream_agent_log(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -892,7 +892,7 @@ def get_console_log(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -926,7 +926,7 @@ def stream_console_log(report_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -976,7 +976,7 @@ def search_graph_tool():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1016,5 +1016,5 @@ def get_graph_statistics_tool():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Step2: Zep实体读取与过滤、OASIS模拟准备与运行(全程自动化
|
|||
"""
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from ..utils.security import safe_traceback
|
||||
from flask import request, jsonify, send_file
|
||||
|
||||
from . import simulation_bp
|
||||
|
|
@ -86,7 +86,7 @@ def get_graph_entities(graph_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ def get_entity_detail(graph_id: str, entity_uuid: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ def get_entities_by_type(graph_id: str, entity_type: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ def create_simulation():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -635,7 +635,7 @@ def prepare_simulation():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -781,7 +781,7 @@ def get_simulation(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -810,7 +810,7 @@ def list_simulations():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -983,7 +983,7 @@ def get_simulation_history():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1021,7 +1021,7 @@ def get_simulation_profiles(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1131,7 +1131,7 @@ def get_simulation_profiles_realtime(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1251,7 +1251,7 @@ def get_simulation_config_realtime(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1287,7 +1287,7 @@ def get_simulation_config(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1316,7 +1316,7 @@ def download_simulation_config(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1368,7 +1368,7 @@ def download_simulation_script(script_name: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1442,7 +1442,7 @@ def generate_profiles():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1637,7 +1637,7 @@ def start_simulation():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1696,7 +1696,7 @@ def stop_simulation():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1756,7 +1756,7 @@ def get_run_status(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1857,7 +1857,7 @@ def get_run_status_detail(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1911,7 +1911,7 @@ def get_simulation_actions(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1951,7 +1951,7 @@ def get_simulation_timeline(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -1978,7 +1978,7 @@ def get_agent_stats(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -2058,7 +2058,7 @@ def get_simulation_posts(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -2133,7 +2133,7 @@ def get_simulation_comments(simulation_id: str):
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -2264,7 +2264,7 @@ def interview_agent():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -2402,7 +2402,7 @@ def interview_agents_batch():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -2505,7 +2505,7 @@ def interview_all_agents():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -2577,7 +2577,7 @@ def get_interview_history():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -2642,7 +2642,7 @@ def get_env_status():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
||||
|
||||
|
|
@ -2712,5 +2712,5 @@ def close_simulation_env():
|
|||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
"traceback": safe_traceback()
|
||||
}), 500
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Dict, Any, List, Optional
|
|||
from enum import Enum
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from ..config import Config
|
||||
from ..utils.security import validate_id
|
||||
|
||||
|
||||
class ProjectStatus(str, Enum):
|
||||
|
|
@ -112,6 +113,7 @@ class ProjectManager:
|
|||
@classmethod
|
||||
def _get_project_dir(cls, project_id: str) -> str:
|
||||
"""获取项目目录路径"""
|
||||
validate_id(project_id, 'project_id') # 路径校验,阻断穿越后再 join/rmtree
|
||||
return os.path.join(cls.PROJECTS_DIR, project_id)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from enum import Enum
|
|||
from ..config import Config
|
||||
from ..utils.llm_client import LLMClient
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.security import validate_id
|
||||
from ..utils.locale import get_language_instruction, t
|
||||
from .zep_tools import (
|
||||
ZepToolsService,
|
||||
|
|
@ -48,6 +49,7 @@ class ReportLogger:
|
|||
Args:
|
||||
report_id: 报告ID,用于确定日志文件路径
|
||||
"""
|
||||
validate_id(report_id, 'report_id') # path check: report_id flows straight into the file path
|
||||
self.report_id = report_id
|
||||
self.log_file_path = os.path.join(
|
||||
Config.UPLOAD_FOLDER, 'reports', report_id, 'agent_log.jsonl'
|
||||
|
|
@ -319,6 +321,7 @@ class ReportConsoleLogger:
|
|||
Args:
|
||||
report_id: 报告ID,用于确定日志文件路径
|
||||
"""
|
||||
validate_id(report_id, 'report_id') # path check: report_id flows straight into the file path
|
||||
self.report_id = report_id
|
||||
self.log_file_path = os.path.join(
|
||||
Config.UPLOAD_FOLDER, 'reports', report_id, 'console_log.txt'
|
||||
|
|
@ -1910,6 +1913,7 @@ class ReportManager:
|
|||
@classmethod
|
||||
def _get_report_folder(cls, report_id: str) -> str:
|
||||
"""获取报告文件夹路径"""
|
||||
validate_id(report_id, 'report_id') # path check before join/rmtree
|
||||
return os.path.join(cls.REPORTS_DIR, report_id)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from enum import Enum
|
|||
|
||||
from ..config import Config
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.security import validate_id
|
||||
from .zep_entity_reader import ZepEntityReader, FilteredEntities
|
||||
from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
|
||||
from .simulation_config_generator import SimulationConfigGenerator, SimulationParameters
|
||||
|
|
@ -138,6 +139,7 @@ class SimulationManager:
|
|||
|
||||
def _get_simulation_dir(self, simulation_id: str) -> str:
|
||||
"""获取模拟数据目录"""
|
||||
validate_id(simulation_id, 'simulation_id') # 路径校验,阻断穿越后再 join/makedirs
|
||||
sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id)
|
||||
os.makedirs(sim_dir, exist_ok=True)
|
||||
return sim_dir
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from queue import Queue
|
|||
from ..config import Config
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.locale import get_locale, set_locale
|
||||
from ..utils.security import validate_id
|
||||
from .zep_graph_memory_updater import ZepGraphMemoryManager
|
||||
from .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse
|
||||
|
||||
|
|
@ -239,10 +240,16 @@ class SimulationRunner:
|
|||
cls._run_states[simulation_id] = state
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _run_dir(cls, simulation_id: str) -> str:
|
||||
"""Validated RUN_STATE_DIR/<simulation_id> -- blocks path traversal before any fs op."""
|
||||
validate_id(simulation_id, 'simulation_id')
|
||||
return os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
|
||||
@classmethod
|
||||
def _load_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]:
|
||||
"""从文件加载运行状态"""
|
||||
state_file = os.path.join(cls.RUN_STATE_DIR, simulation_id, "run_state.json")
|
||||
state_file = os.path.join(cls._run_dir(simulation_id), "run_state.json")
|
||||
if not os.path.exists(state_file):
|
||||
return None
|
||||
|
||||
|
|
@ -298,7 +305,7 @@ class SimulationRunner:
|
|||
@classmethod
|
||||
def _save_run_state(cls, state: SimulationRunState):
|
||||
"""保存运行状态到文件"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)
|
||||
sim_dir = cls._run_dir(state.simulation_id)
|
||||
os.makedirs(sim_dir, exist_ok=True)
|
||||
state_file = os.path.join(sim_dir, "run_state.json")
|
||||
|
||||
|
|
@ -331,13 +338,16 @@ class SimulationRunner:
|
|||
Returns:
|
||||
SimulationRunState
|
||||
"""
|
||||
# 路径校验:simulation_id 会进入 RUN_STATE_DIR 下的 join/makedirs 与子进程参数
|
||||
validate_id(simulation_id, 'simulation_id')
|
||||
|
||||
# 检查是否已在运行
|
||||
existing = cls.get_run_state(simulation_id)
|
||||
if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]:
|
||||
raise ValueError(f"模拟已在运行中: {simulation_id}")
|
||||
|
||||
# 加载模拟配置
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
config_path = os.path.join(sim_dir, "simulation_config.json")
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
|
|
@ -495,7 +505,7 @@ class SimulationRunner:
|
|||
def _monitor_simulation(cls, simulation_id: str, locale: str = 'zh'):
|
||||
"""监控模拟进程,解析动作日志"""
|
||||
set_locale(locale)
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
|
||||
# 新的日志结构:分平台的动作日志
|
||||
twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
|
||||
|
|
@ -713,7 +723,7 @@ class SimulationRunner:
|
|||
Returns:
|
||||
True 如果所有启用的平台都已完成
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)
|
||||
sim_dir = cls._run_dir(state.simulation_id)
|
||||
twitter_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
|
||||
reddit_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
|
||||
|
||||
|
|
@ -923,7 +933,7 @@ class SimulationRunner:
|
|||
Returns:
|
||||
完整的动作列表(按时间戳排序,新的在前)
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
actions = []
|
||||
|
||||
# 读取 Twitter 动作文件(根据文件路径自动设置 platform 为 twitter)
|
||||
|
|
@ -1137,7 +1147,7 @@ class SimulationRunner:
|
|||
"""
|
||||
import shutil
|
||||
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
|
||||
if not os.path.exists(sim_dir):
|
||||
return {"success": True, "message": "模拟目录不存在,无需清理"}
|
||||
|
|
@ -1255,7 +1265,7 @@ class SimulationRunner:
|
|||
|
||||
# 同时更新 state.json,将状态设为 stopped
|
||||
try:
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
state_file = os.path.join(sim_dir, "state.json")
|
||||
logger.info(f"尝试更新 state.json: {state_file}")
|
||||
if os.path.exists(state_file):
|
||||
|
|
@ -1394,7 +1404,7 @@ class SimulationRunner:
|
|||
Returns:
|
||||
True 表示环境存活,False 表示环境已关闭
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
if not os.path.exists(sim_dir):
|
||||
return False
|
||||
|
||||
|
|
@ -1412,7 +1422,7 @@ class SimulationRunner:
|
|||
Returns:
|
||||
状态详情字典,包含 status, twitter_available, reddit_available, timestamp
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
status_file = os.path.join(sim_dir, "env_status.json")
|
||||
|
||||
default_status = {
|
||||
|
|
@ -1466,7 +1476,7 @@ class SimulationRunner:
|
|||
ValueError: 模拟不存在或环境未运行
|
||||
TimeoutError: 等待响应超时
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
if not os.path.exists(sim_dir):
|
||||
raise ValueError(f"模拟不存在: {simulation_id}")
|
||||
|
||||
|
|
@ -1528,7 +1538,7 @@ class SimulationRunner:
|
|||
ValueError: 模拟不存在或环境未运行
|
||||
TimeoutError: 等待响应超时
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
if not os.path.exists(sim_dir):
|
||||
raise ValueError(f"模拟不存在: {simulation_id}")
|
||||
|
||||
|
|
@ -1585,7 +1595,7 @@ class SimulationRunner:
|
|||
Returns:
|
||||
全局采访结果字典
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
if not os.path.exists(sim_dir):
|
||||
raise ValueError(f"模拟不存在: {simulation_id}")
|
||||
|
||||
|
|
@ -1638,7 +1648,7 @@ class SimulationRunner:
|
|||
Returns:
|
||||
操作结果字典
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
if not os.path.exists(sim_dir):
|
||||
raise ValueError(f"模拟不存在: {simulation_id}")
|
||||
|
||||
|
|
@ -1749,7 +1759,7 @@ class SimulationRunner:
|
|||
Returns:
|
||||
Interview历史记录列表
|
||||
"""
|
||||
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
||||
sim_dir = cls._run_dir(simulation_id)
|
||||
|
||||
results = []
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
安全辅助函数(评审 H5 / 上传嗅探 / 路径校验)。
|
||||
- safe_traceback: 仅在 DEBUG 时把堆栈返回客户端,生产环境只记到服务端日志
|
||||
- validate_id: 校验 URL 传入的 id,避免落入文件系统 sink(路径穿越/异常字符)
|
||||
- upload_content_ok: 按魔术字节嗅探上传内容,防止改扩展名的二进制混入
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import traceback as _traceback
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
# 允许的 id 字符集:字母数字 + 下划线 + 连字符,长度 1-64。
|
||||
# 排除 '/'、'.'、'\\' 等可用于路径穿越或越级的字符。
|
||||
_ID_RE = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
|
||||
|
||||
|
||||
def safe_traceback() -> str:
|
||||
"""
|
||||
H5:完整堆栈始终写入服务端日志;仅在 DEBUG 模式才把堆栈返回客户端,
|
||||
生产环境返回通用提示,避免向客户端泄露内部路径/栈帧。
|
||||
"""
|
||||
# 延迟导入 Config,避免与配置模块的潜在循环依赖
|
||||
from ..config import Config
|
||||
tb = _traceback.format_exc()
|
||||
get_logger('mirofish.error').error(tb)
|
||||
return tb if Config.DEBUG else 'Internal server error (see server logs)'
|
||||
|
||||
|
||||
def validate_id(value: str, kind: str = 'id') -> str:
|
||||
"""
|
||||
路径校验:拒绝任何不匹配 _ID_RE 的 id(含 '..'、'/'、空值),
|
||||
在 id 进入 os.path.join / makedirs / rmtree 之前阻断路径穿越。
|
||||
"""
|
||||
if not isinstance(value, str) or not _ID_RE.match(value):
|
||||
raise ValueError(f'Invalid {kind}: {value!r}')
|
||||
return value
|
||||
|
||||
|
||||
def upload_content_ok(file_storage, filename: str) -> bool:
|
||||
"""
|
||||
上传嗅探:按扩展名校验文件头部内容,使改名的二进制无法通过扩展名白名单。
|
||||
- pdf:必须以 %PDF- 开头
|
||||
- txt/md/markdown:头部不得含 NUL 字节(典型二进制特征)
|
||||
读取后将流指针复位,避免影响后续保存。
|
||||
"""
|
||||
ext = os.path.splitext(filename)[1].lower().lstrip('.')
|
||||
head = file_storage.read(512)
|
||||
file_storage.seek(0)
|
||||
if ext == 'pdf':
|
||||
return head[:5] == b'%PDF-'
|
||||
if ext in ('txt', 'md', 'markdown'):
|
||||
# 接受带 BOM 的 UTF-16/UTF-32/UTF-8 文本(这些合法文本会含 NUL 字节,
|
||||
# 与 file_parser 的多编码支持一致);否则按头部含 NUL 判定为二进制并拒绝。
|
||||
if head.startswith((b'\xff\xfe', b'\xfe\xff', b'\xef\xbb\xbf')):
|
||||
return True
|
||||
return b'\x00' not in head
|
||||
return False
|
||||
|
|
@ -6,6 +6,11 @@
|
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@100..800&family=Noto+Sans+SC:wght@300;400;500;700;800;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||
<meta charset="UTF-8" />
|
||||
<!-- CSP(纵深防御,配合 DOMPurify 抵御 XSS)。注意:
|
||||
- script-src 含 'unsafe-inline' 仅因本文件顶部有一段内联 lang 脚本;移除该脚本可去掉它以收紧策略。
|
||||
- connect-src 默认含本机后端;若后端在其它域,部署时需把该域加入 connect-src。
|
||||
- frame-ancestors 在 <meta> 中无效,已由服务端/预览响应头(X-Frame-Options / CSP 头)补充。 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' http://localhost:5001; object-src 'none'; base-uri 'self'; form-action 'self'" />
|
||||
<link rel="icon" type="image/png" href="/icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="MiroFish - 社交媒体舆论模拟系统" />
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ export default defineConfig({
|
|||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
// 安全响应头(生产由 vite preview 提供前端时生效)。frame-ancestors 仅在响应头中有效,
|
||||
// 故在此补充;其余 CSP 指令由 index.html 的 <meta> 提供(两者一致)。
|
||||
headers: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy': "frame-ancestors 'none'"
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5001',
|
||||
|
|
|
|||
Loading…
Reference in New Issue