diff --git a/backend/app/__init__.py b/backend/app/__init__.py
index c2e30229..7328e9f5 100644
--- a/backend/app/__init__.py
+++ b/backend/app/__init__.py
@@ -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 的
+ # + 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')
diff --git a/backend/app/api/graph.py b/backend/app/api/graph.py
index 759ff48b..842c729c 100644
--- a/backend/app/api/graph.py
+++ b/backend/app/api/graph.py
@@ -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
diff --git a/backend/app/api/report.py b/backend/app/api/report.py
index d7f2a4d0..b4f2ee07 100644
--- a/backend/app/api/report.py
+++ b/backend/app/api/report.py
@@ -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
diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py
index 3a8e1e3f..7526b8e6 100644
--- a/backend/app/api/simulation.py
+++ b/backend/app/api/simulation.py
@@ -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
diff --git a/backend/app/models/project.py b/backend/app/models/project.py
index 08978937..9bbd5c09 100644
--- a/backend/app/models/project.py
+++ b/backend/app/models/project.py
@@ -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
diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py
index cecd70b4..2fd6b4c5 100644
--- a/backend/app/services/report_agent.py
+++ b/backend/app/services/report_agent.py
@@ -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
diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py
index 0d161a90..4750ecc5 100644
--- a/backend/app/services/simulation_manager.py
+++ b/backend/app/services/simulation_manager.py
@@ -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
diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py
index 046ef6da..94c3823c 100644
--- a/backend/app/services/simulation_runner.py
+++ b/backend/app/services/simulation_runner.py
@@ -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/ -- 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 = []
diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py
new file mode 100644
index 00000000..31133eb3
--- /dev/null
+++ b/backend/app/utils/security.py
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
index 0b80095c..0eda42d0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -6,6 +6,11 @@
+
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 94505343..91cc231b 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -28,6 +28,14 @@ export default defineConfig({
preview: {
host: '0.0.0.0',
port: 3000,
+ // 安全响应头(生产由 vite preview 提供前端时生效)。frame-ancestors 仅在响应头中有效,
+ // 故在此补充;其余 CSP 指令由 index.html 的 提供(两者一致)。
+ 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',