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:
Yo-LRK 2026-06-13 18:00:28 +07:00
parent 394638d426
commit ed840c7a00
11 changed files with 173 additions and 73 deletions

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 = []

View File

@ -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

View File

@ -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 - 社交媒体舆论模拟系统" />

View File

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