security: gate unexpected-error detail to clients (residual low)

Follow-up to the traceback gate: client-facing exception detail is now gated too.

- safe_error(e) (app/utils/security.py): returns the exception string only when
  FLASK_DEBUG, otherwise a generic message. Full detail stays in server logs.
- All catch-all `except Exception` handlers in api/{graph,report,simulation}.py now
  return safe_error(e) instead of str(e); same for the persisted error fields
  (project.error / state.error / task fail messages). Typed `except ValueError`
  validation handlers (404/400) keep str(e) — those are intentional, actionable
  user messages that echo only user-supplied ids.
- graph_builder no longer puts a full traceback into the task error (logs it
  server-side with exc_info, surfaces safe_error to the client); the batch-failure
  progress message is gated too.
- README documents the CSP connect-src ↔ VITE_API_BASE_URL coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yo-LRK 2026-06-13 18:23:03 +07:00
parent ed840c7a00
commit 392a971270
6 changed files with 84 additions and 66 deletions

View File

@ -163,6 +163,11 @@ API_KEY=your_strong_api_key
`.env.example` for defaults.
- Run the production server with a **single worker** (`gunicorn -w 1 --threads N`); simulation
run-state is held in-process, so multiple workers break stop/status routing.
- **CSP / API origin:** the frontend ships a Content-Security-Policy whose `connect-src` allows
`'self'` + `http://localhost:5001` (the default API). If you point the UI at a different API
host (set `VITE_API_BASE_URL`), you **must** add that origin to `connect-src` in
`frontend/index.html` too, or the browser will silently block all API calls. Restrict
`ALLOWED_ORIGINS` (backend) to your real frontend origin in production.
#### 2. Install Dependencies

View File

@ -4,7 +4,7 @@
"""
import os
from ..utils.security import safe_traceback, upload_content_ok
from ..utils.security import safe_traceback, safe_error, upload_content_ok
import threading
from flask import request, jsonify
@ -251,7 +251,7 @@ def generate_ontology():
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -499,13 +499,13 @@ def build_graph():
build_logger.debug(safe_traceback())
project.status = ProjectStatus.FAILED
project.error = str(e)
project.error = safe_error(e)
ProjectManager.save_project(project)
task_manager.update_task(
task_id,
status=TaskStatus.FAILED,
message=t('progress.buildFailed', error=str(e)),
message=t('progress.buildFailed', error=safe_error(e)),
error=safe_traceback()
)
@ -525,7 +525,7 @@ def build_graph():
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -590,7 +590,7 @@ def get_graph_data(graph_id: str):
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -618,6 +618,6 @@ def delete_graph(graph_id: str):
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500

View File

@ -4,7 +4,7 @@ Report API路由
"""
import os
from ..utils.security import safe_traceback
from ..utils.security import safe_traceback, safe_error
import threading
from flask import request, jsonify, send_file
@ -173,7 +173,7 @@ def generate_report():
except Exception as e:
logger.error(f"报告生成失败: {str(e)}")
task_manager.fail_task(task_id, str(e))
task_manager.fail_task(task_id, safe_error(e))
# 启动后台线程
thread = threading.Thread(target=run_generate, daemon=True)
@ -195,7 +195,7 @@ def generate_report():
logger.error(f"启动报告生成任务失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -268,7 +268,7 @@ def get_generate_status():
logger.error(f"查询任务状态失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e)
"error": safe_error(e)
}), 500
@ -311,7 +311,7 @@ def get_report(report_id: str):
logger.error(f"获取报告失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -350,7 +350,7 @@ def get_report_by_simulation(simulation_id: str):
logger.error(f"获取报告失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -390,7 +390,7 @@ def list_reports():
logger.error(f"列出报告失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -436,7 +436,7 @@ def download_report(report_id: str):
logger.error(f"下载报告失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -462,7 +462,7 @@ def delete_report(report_id: str):
logger.error(f"删除报告失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -559,7 +559,7 @@ def chat_with_report_agent():
logger.error(f"对话失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -602,7 +602,7 @@ def get_report_progress(report_id: str):
logger.error(f"获取报告进度失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -653,7 +653,7 @@ def get_report_sections(report_id: str):
logger.error(f"获取章节列表失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -697,7 +697,7 @@ def get_single_section(report_id: str, section_index: int):
logger.error(f"获取章节内容失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -748,7 +748,7 @@ def check_report_status(simulation_id: str):
logger.error(f"检查报告状态失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -809,7 +809,7 @@ def get_agent_log(report_id: str):
logger.error(f"获取Agent日志失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -843,7 +843,7 @@ def stream_agent_log(report_id: str):
logger.error(f"获取Agent日志失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -891,7 +891,7 @@ def get_console_log(report_id: str):
logger.error(f"获取控制台日志失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -925,7 +925,7 @@ def stream_console_log(report_id: str):
logger.error(f"获取控制台日志失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -975,7 +975,7 @@ def search_graph_tool():
logger.error(f"图谱搜索失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1015,6 +1015,6 @@ def get_graph_statistics_tool():
logger.error(f"获取图谱统计失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500

View File

@ -4,7 +4,7 @@ Step2: Zep实体读取与过滤、OASIS模拟准备与运行全程自动化
"""
import os
from ..utils.security import safe_traceback
from ..utils.security import safe_traceback, safe_error
from flask import request, jsonify, send_file
from . import simulation_bp
@ -85,7 +85,7 @@ def get_graph_entities(graph_id: str):
logger.error(f"获取图谱实体失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -118,7 +118,7 @@ def get_entity_detail(graph_id: str, entity_uuid: str):
logger.error(f"获取实体详情失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -155,7 +155,7 @@ def get_entities_by_type(graph_id: str, entity_type: str):
logger.error(f"获取实体失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -232,7 +232,7 @@ def create_simulation():
logger.error(f"创建模拟失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -597,14 +597,14 @@ def prepare_simulation():
)
except Exception as e:
logger.error(f"准备模拟失败: {str(e)}")
task_manager.fail_task(task_id, str(e))
logger.error(f"准备模拟失败: {e}", exc_info=True)
task_manager.fail_task(task_id, safe_error(e))
# 更新模拟状态为失败
state = manager.get_simulation(simulation_id)
if state:
state.status = SimulationStatus.FAILED
state.error = str(e)
state.error = safe_error(e)
manager._save_simulation_state(state)
# 启动后台线程
@ -634,7 +634,7 @@ def prepare_simulation():
logger.error(f"启动准备任务失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -748,7 +748,7 @@ def get_prepare_status():
logger.error(f"查询任务状态失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e)
"error": safe_error(e)
}), 500
@ -780,7 +780,7 @@ def get_simulation(simulation_id: str):
logger.error(f"获取模拟状态失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -809,7 +809,7 @@ def list_simulations():
logger.error(f"列出模拟失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -982,7 +982,7 @@ def get_simulation_history():
logger.error(f"获取历史模拟失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1020,7 +1020,7 @@ def get_simulation_profiles(simulation_id: str):
logger.error(f"获取Profile失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1130,7 +1130,7 @@ def get_simulation_profiles_realtime(simulation_id: str):
logger.error(f"实时获取Profile失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1250,7 +1250,7 @@ def get_simulation_config_realtime(simulation_id: str):
logger.error(f"实时获取Config失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1286,7 +1286,7 @@ def get_simulation_config(simulation_id: str):
logger.error(f"获取配置失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1315,7 +1315,7 @@ def download_simulation_config(simulation_id: str):
logger.error(f"下载配置失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1367,7 +1367,7 @@ def download_simulation_script(script_name: str):
logger.error(f"下载脚本失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1441,7 +1441,7 @@ def generate_profiles():
logger.error(f"生成Profile失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1636,7 +1636,7 @@ def start_simulation():
logger.error(f"启动模拟失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1695,7 +1695,7 @@ def stop_simulation():
logger.error(f"停止模拟失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1755,7 +1755,7 @@ def get_run_status(simulation_id: str):
logger.error(f"获取运行状态失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1856,7 +1856,7 @@ def get_run_status_detail(simulation_id: str):
logger.error(f"获取详细状态失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1910,7 +1910,7 @@ def get_simulation_actions(simulation_id: str):
logger.error(f"获取动作历史失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1950,7 +1950,7 @@ def get_simulation_timeline(simulation_id: str):
logger.error(f"获取时间线失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -1977,7 +1977,7 @@ def get_agent_stats(simulation_id: str):
logger.error(f"获取Agent统计失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -2057,7 +2057,7 @@ def get_simulation_posts(simulation_id: str):
logger.error(f"获取帖子失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -2132,7 +2132,7 @@ def get_simulation_comments(simulation_id: str):
logger.error(f"获取评论失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -2263,7 +2263,7 @@ def interview_agent():
logger.error(f"Interview失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -2401,7 +2401,7 @@ def interview_agents_batch():
logger.error(f"批量Interview失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -2504,7 +2504,7 @@ def interview_all_agents():
logger.error(f"全局Interview失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -2576,7 +2576,7 @@ def get_interview_history():
logger.error(f"获取Interview历史失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -2641,7 +2641,7 @@ def get_env_status():
logger.error(f"获取环境状态失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
@ -2711,6 +2711,6 @@ def close_simulation_env():
logger.error(f"关闭环境失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"error": safe_error(e),
"traceback": safe_traceback()
}), 500

View File

@ -18,6 +18,10 @@ from ..models.task import TaskManager, TaskStatus
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from .text_processor import TextProcessor
from ..utils.locale import t, get_locale, set_locale
from ..utils.logger import get_logger
from ..utils.security import safe_error
logger = get_logger('mirofish.graph_builder')
@dataclass
@ -186,9 +190,8 @@ class GraphBuilderService:
})
except Exception as e:
import traceback
error_msg = f"{str(e)}\n{traceback.format_exc()}"
self.task_manager.fail_task(task_id, error_msg)
logger.error(f"graph build failed (task {task_id})", exc_info=True)
self.task_manager.fail_task(task_id, safe_error(e))
def create_graph(self, name: str) -> str:
"""创建Zep图谱公开方法"""
@ -339,7 +342,7 @@ class GraphBuilderService:
except Exception as e:
if progress_callback:
progress_callback(t('progress.batchFailed', batch=batch_num, error=str(e)), 0)
progress_callback(t('progress.batchFailed', batch=batch_num, error=safe_error(e)), 0)
raise
return episode_uuids

View File

@ -28,6 +28,16 @@ def safe_traceback() -> str:
return tb if Config.DEBUG else 'Internal server error (see server logs)'
def safe_error(e) -> str:
"""
客户端可见的错误文案DEBUG 模式返回异常消息生产环境返回通用提示
避免异常消息本身 FileNotFoundError 的路径ValueError 里的配置值泄露给客户端
完整异常仍由各调用点的 logger.error / safe_traceback 记到服务端日志
"""
from ..config import Config
return str(e) if Config.DEBUG else 'Internal server error (see server logs)'
def validate_id(value: str, kind: str = 'id') -> str:
"""
路径校验拒绝任何不匹配 _ID_RE id '..''/'空值