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',