From 8cb92768aebc4aed06d3a3544cdba13566f45fbd Mon Sep 17 00:00:00 2001 From: Yo-LRK Date: Sat, 13 Jun 2026 17:19:05 +0700 Subject: [PATCH 1/4] security: harden C1-C4 (debug-RCE, auth, denial-of-wallet, sim deadline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four CRITICAL findings from the 2026-06-13 review. C1 — Werkzeug debug RCE / dev server in prod: - FLASK_DEBUG defaults False (kills interactive-debugger network RCE) - production runs gunicorn (-w 1 --threads 8) via `npm run start`; Dockerfile builds the frontend and serves it with `vite preview` (host 0.0.0.0); gunicorn + uv.lock updated - Config.validate() now runs inside create_app() so the gunicorn path enforces SECRET_KEY (prod) / API_KEY / LLM / ZEP at boot C2 — zero auth on all /api/* routes: - before_request API-key guard (X-API-Key / Bearer), constant-time bytes compare, /health + OPTIONS exempt - AUTH_ENABLED fail-closed parse (only explicit false/0/no/off disables) - frontend axios injects X-API-Key from build-time VITE_API_KEY, wired through docker compose build-arg -> Dockerfile ARG -> vite build (+ frontend/.env.example) C3 — denial-of-wallet (no cost ceiling; OASIS_DEFAULT_MAX_ROUNDS was dead config): - OASIS_DEFAULT_MAX_ROUNDS now applied when max_rounds omitted (default 150, covers the 144-round demo); hard ceilings OASIS_MAX_ROUNDS_CAP / OASIS_MAX_AGENTS_CAP; runner always forwards the clamped rounds to the subprocess C4 — no simulation deadline; env.step could wedge forever: - every env.step (initial / round-loop / interview) wrapped in asyncio.wait_for (OASIS_ROUND_TIMEOUT_SEC) across all 3 run scripts; per-loop total-deadline (OASIS_RUN_TIMEOUT_SEC); gather(return_exceptions=True) + single-platform try/except so one platform's failure can't skip env.close New env vars documented in .env.example + README security section. Co-Authored-By: Claude Opus 4.8 --- .env.example | 30 +++++++- Dockerfile | 14 +++- README.md | 37 ++++++++++ backend/app/__init__.py | 45 +++++++++++- backend/app/config.py | 33 ++++++++- backend/app/services/simulation_runner.py | 35 +++++++--- backend/pyproject.toml | 4 +- backend/requirements.txt | 3 + backend/scripts/run_parallel_simulation.py | 81 ++++++++++++++++++---- backend/scripts/run_reddit_simulation.py | 32 +++++++-- backend/scripts/run_twitter_simulation.py | 32 +++++++-- backend/uv.lock | 14 ++++ docker-compose.yml | 7 ++ frontend/.env.example | 9 +++ frontend/src/api/index.js | 9 +++ frontend/vite.config.js | 14 ++++ package.json | 5 +- 17 files changed, 356 insertions(+), 48 deletions(-) create mode 100644 frontend/.env.example diff --git a/.env.example b/.env.example index 78a3b72c07..5d90cd3217 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,32 @@ ZEP_API_KEY=your_zep_api_key_here # 注意如果不使用加速配置,env文件中就不要出现下面的配置项 LLM_BOOST_API_KEY=your_api_key_here LLM_BOOST_BASE_URL=your_base_url_here -LLM_BOOST_MODEL_NAME=your_model_name_here \ No newline at end of file +LLM_BOOST_MODEL_NAME=your_model_name_here + +# ===== 安全配置(C1)===== +# 生产模式下必须设置自定义 SECRET_KEY(否则启动校验失败)。生成示例:python -c "import secrets;print(secrets.token_hex(32))" +SECRET_KEY=change_me_to_a_random_secret +# 调试模式默认关闭;设为 true 会启用 Werkzeug 交互式调试器(可远程 RCE),切勿在联网/生产开启 +FLASK_DEBUG=false + +# ===== API 鉴权(C2)===== +# 所有 /api/* 端点需携带 `X-API-Key: `(或 `Authorization: Bearer `) +# AUTH_ENABLED=true(默认)时必须设置 API_KEY;本地开发可设 AUTH_ENABLED=false 关闭鉴权 +AUTH_ENABLED=true +API_KEY=change_me_to_a_strong_api_key +# 前端构建期变量:必须等于 API_KEY。docker compose 会把它作为 build-arg 注入前端构建, +# 使打包后的 UI 自动带上 X-API-Key。注意:它会被打进客户端包、可被任何访问者提取(见 README 安全说明)。 +VITE_API_KEY=change_me_to_a_strong_api_key + +# ===== 模拟成本上限(C3,denial-of-wallet 防护)===== +# 客户端未传 max_rounds 时的默认轮数上限(完整长度模拟请按请求传 max_rounds 或调高此值) +# 默认 150 覆盖典型配置(72h/30min=144 轮)以免悄悄截断标准演示 +OASIS_DEFAULT_MAX_ROUNDS=150 +# 硬上限:无论客户端传入何值都不得超过 +OASIS_MAX_ROUNDS_CAP=200 +OASIS_MAX_AGENTS_CAP=1000 + +# ===== 模拟超时(C4,秒)===== +# 单轮 env.step 超时 + 整轮模拟总超时,防止 LLM/网络挂起导致 run 永久 wedge +OASIS_ROUND_TIMEOUT_SEC=600 +OASIS_RUN_TIMEOUT_SEC=7200 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e656468603..0660dbc228 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,17 @@ RUN npm ci \ # 复制项目源码 COPY . . +# C2:前端 API Key 是 Vite 构建期变量,必须在 `npm run build` 之前进入构建环境,否则打包后的 +# UI 带空 key、在 AUTH_ENABLED=true 下所有 /api/* 会 401。由 docker compose 经 build-arg 注入。 +ARG VITE_API_KEY="" +ENV VITE_API_KEY=$VITE_API_KEY + +# C1:构建前端静态产物,生产用 `vite preview` 提供(不再运行 Vite 开发服务器) +RUN npm run build + EXPOSE 3000 5001 -# 同时启动前后端(开发模式) -CMD ["npm", "run", "dev"] \ No newline at end of file +# C1:生产启动 —— 后端用 gunicorn(单 worker 多线程,保留进程内模拟态), +# 前端用 vite preview 提供已构建产物。开发请改用 `npm run dev`。 +# 安全前提:须经 .env 设置 SECRET_KEY 与 API_KEY(FLASK_DEBUG 默认 false)。 +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/README.md b/README.md index de082935a7..1dbe7bf626 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,43 @@ LLM_MODEL_NAME=qwen-plus ZEP_API_KEY=your_zep_api_key ``` +#### Security configuration (required for production) + +The backend ships secure-by-default. When `FLASK_DEBUG=false` (the default) the app +runs under **gunicorn** (no Werkzeug debugger) and refuses to start unless these are set: + +```env +# A random secret (required when FLASK_DEBUG=false): +# python -c "import secrets;print(secrets.token_hex(32))" +SECRET_KEY=your_random_secret + +# API-key auth is ON by default — every /api/* request must carry the key. +# Clients send X-API-Key: (or Authorization: Bearer ). +AUTH_ENABLED=true +API_KEY=your_strong_api_key +``` + +- The bundled web UI reads the key from the **build-time** var **`VITE_API_KEY`** (set it equal to + `API_KEY`) and sends it automatically as `X-API-Key`. It must be present **before** the frontend + is built: + - **Docker:** put `VITE_API_KEY` in the root `.env`; `docker compose up --build` injects it as a + build-arg (docker-compose.yml `build.args` → Dockerfile `ARG VITE_API_KEY` → `npm run build`). + The pre-built `ghcr.io` image (used by a bare `docker compose up` without `--build`) bakes no + custom key — rebuild, or use `AUTH_ENABLED=false` for that path. + - **Local frontend build:** copy `frontend/.env.example` → `frontend/.env` and set `VITE_API_KEY`, + then `npm run build`. (The root `.env` is read by the backend only, not by Vite.) + - ⚠️ A key baked into the client bundle is extractable by anyone who loads the page — for + multi-tenant/public exposure replace this with session login or a gateway that injects per-user + tokens. For local/internal/VPN or behind-a-gateway single-host use it is sufficient. +- Local development / simplest single-host demo: set `AUTH_ENABLED=false` to disable the key + requirement entirely (the API is then protected only by your network boundary). +- Cost controls (denial-of-wallet): a run is bounded by `OASIS_DEFAULT_MAX_ROUNDS` (when the + client omits `max_rounds`), the hard ceilings `OASIS_MAX_ROUNDS_CAP` / `OASIS_MAX_AGENTS_CAP`, + and per-round / total timeouts `OASIS_ROUND_TIMEOUT_SEC` / `OASIS_RUN_TIMEOUT_SEC`. See + `.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. + #### 2. Install Dependencies ```bash diff --git a/backend/app/__init__.py b/backend/app/__init__.py index aba624bba9..3954732620 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,6 +2,7 @@ MiroFish Backend - Flask应用工厂 """ +import hmac import os import warnings @@ -9,7 +10,7 @@ # 需要在所有其他导入之前设置 warnings.filterwarnings("ignore", message=".*resource_tracker.*") -from flask import Flask, request +from flask import Flask, jsonify, request from flask_cors import CORS from .config import Config @@ -18,9 +19,16 @@ def create_app(config_class=Config): """Flask应用工厂函数""" + # 配置校验(C1/C2):在工厂内执行,确保 gunicorn(生产)路径也强制校验。 + # run.py(开发入口)也会单独校验,这里覆盖 `gunicorn app:create_app()` 这条不经过 run.py 的路径, + # 否则 SECRET_KEY/API_KEY/LLM_API_KEY/ZEP_API_KEY 的缺省检查在生产中形同虚设。 + config_errors = config_class.validate() + if config_errors: + raise RuntimeError("配置错误,无法启动:\n - " + "\n - ".join(config_errors)) + app = Flask(__name__) app.config.from_object(config_class) - + # 设置JSON编码:确保中文直接显示(而不是 \uXXXX 格式) # Flask >= 2.3 使用 app.json.ensure_ascii,旧版本使用 JSON_AS_ASCII 配置 if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'): @@ -41,7 +49,38 @@ def create_app(config_class=Config): # 启用CORS CORS(app, resources={r"/api/*": {"origins": "*"}}) - + + # API Key 鉴权(C2):所有 /api/* 端点强制鉴权。 + # 客户端通过 `X-API-Key: ` 或 `Authorization: Bearer ` 传入。 + # /health 等非 /api 路径豁免;CORS 预检(OPTIONS)放行(浏览器预检不带自定义头)。 + @app.before_request + def require_api_key(): + if not Config.AUTH_ENABLED: + return None + path = request.path or '' + if not path.startswith('/api/'): + return None + if request.method == 'OPTIONS': + return None + provided = request.headers.get('X-API-Key', '') + if not provided: + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + provided = auth_header[7:] + expected = Config.API_KEY or '' + if not expected: + return jsonify({"success": False, "error": "Unauthorized"}), 401 + # 常量时间比较,避免时序侧信道。两侧编码为 bytes —— compare_digest 对含非 ASCII 字符的 + # str 会抛 TypeError;编码后任何输入都安全,绝不让鉴权拒绝路径崩成 500。 + try: + ok = hmac.compare_digest(provided.encode('utf-8'), expected.encode('utf-8')) + except Exception: + ok = False + if not ok: + return jsonify({"success": False, "error": "Unauthorized"}), 401 + return None + + # 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程) from .services.simulation_runner import SimulationRunner SimulationRunner.register_cleanup() diff --git a/backend/app/config.py b/backend/app/config.py index de63e2b4b0..0a6b90dd63 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,9 +21,19 @@ class Config: """Flask配置类""" # Flask配置 + # 注意:SECRET_KEY 的默认值是公开值,仅供 DEBUG 模式使用;生产模式(DEBUG=false) + # 必须通过环境变量设置自定义值(见 validate())。 SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key') - DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true' - + # 安全默认(C1):DEBUG 默认关闭,避免误把 Werkzeug 交互式调试器(可远程 RCE)暴露到网络。 + DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' + + # 认证配置(C2):所有 /api/* 端点强制 API Key 鉴权 + # AUTH_ENABLED 默认开启;本地开发可显式设 AUTH_ENABLED=false 关闭。 + # fail-closed 解析:仅显式 false/0/no/off 才关闭鉴权;其余任何值(含空白、拼写错误、 + # 带尾换行的 'true\n'、'1'、'yes' 等)一律视为开启,避免 env 配置失误悄悄回到零鉴权。 + API_KEY = os.environ.get('API_KEY') + AUTH_ENABLED = os.environ.get('AUTH_ENABLED', 'true').strip().lower() not in ('false', '0', 'no', 'off') + # JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式) JSON_AS_ASCII = False @@ -45,7 +55,18 @@ class Config: DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小 # OASIS模拟配置 - OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10')) + # OASIS_DEFAULT_MAX_ROUNDS(C3):客户端未显式传 max_rounds 时应用的默认轮数上限。 + # 之前此常量从未被引用(dead config),现已在 SimulationRunner 中生效。默认 150 覆盖 + # 典型配置(72h/30min = 144 轮)以免悄悄截断标准演示;更长的配置会被截到此值,且无论如何 + # 都不会超过硬上限 OASIS_MAX_ROUNDS_CAP。匿名 denial-of-wallet 已由 C2 鉴权堵住,此处 + # 仅约束“已鉴权客户端”单次运行的成本上界。 + OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '150')) + # 硬上限(C3,denial-of-wallet 防护):无论客户端传入何值,轮数/agent 数都不得超过这些上限。 + OASIS_MAX_ROUNDS_CAP = int(os.environ.get('OASIS_MAX_ROUNDS_CAP', '200')) + OASIS_MAX_AGENTS_CAP = int(os.environ.get('OASIS_MAX_AGENTS_CAP', '1000')) + # 模拟超时(C4,秒):每轮 env.step 超时 + 整轮模拟总超时。子进程读取同名环境变量。 + OASIS_ROUND_TIMEOUT_SEC = int(os.environ.get('OASIS_ROUND_TIMEOUT_SEC', '600')) + OASIS_RUN_TIMEOUT_SEC = int(os.environ.get('OASIS_RUN_TIMEOUT_SEC', '7200')) OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations') # OASIS平台可用动作配置 @@ -71,5 +92,11 @@ def validate(cls) -> list[str]: errors.append("LLM_API_KEY 未配置") if not cls.ZEP_API_KEY: errors.append("ZEP_API_KEY 未配置") + # C1:生产模式必须设置自定义 SECRET_KEY(默认值是公开值,可伪造签名 / 削弱调试器 PIN) + if not cls.DEBUG and cls.SECRET_KEY == 'mirofish-secret-key': + errors.append("生产模式(FLASK_DEBUG=false)必须设置自定义 SECRET_KEY") + # C2:开启鉴权时必须配置 API_KEY,否则所有 /api/* 都会 401 + if cls.AUTH_ENABLED and not cls.API_KEY: + errors.append("AUTH_ENABLED=true 时必须设置 API_KEY(或显式 AUTH_ENABLED=false 关闭鉴权)") return errors diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py index e86021f808..046ef6da5e 100644 --- a/backend/app/services/simulation_runner.py +++ b/backend/app/services/simulation_runner.py @@ -351,14 +351,27 @@ def start_simulation( total_hours = time_config.get("total_simulation_hours", 72) minutes_per_round = time_config.get("minutes_per_round", 30) total_rounds = int(total_hours * 60 / minutes_per_round) - - # 如果指定了最大轮数,则截断 - if max_rounds is not None and max_rounds > 0: - original_rounds = total_rounds - total_rounds = min(total_rounds, max_rounds) - if total_rounds < original_rounds: - logger.info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") - + + # C3(denial-of-wallet 防护):限制总轮数。 + # - max_rounds 未指定时,应用服务端默认上限 OASIS_DEFAULT_MAX_ROUNDS(之前为 dead config) + # - 无论是否指定,都不得超过硬上限 OASIS_MAX_ROUNDS_CAP + effective_max = max_rounds if (max_rounds is not None and max_rounds > 0) else Config.OASIS_DEFAULT_MAX_ROUNDS + effective_max = min(effective_max, Config.OASIS_MAX_ROUNDS_CAP) + if total_rounds > effective_max: + logger.info( + f"轮数已限制: {total_rounds} -> {effective_max} " + f"(max_rounds={max_rounds}, default={Config.OASIS_DEFAULT_MAX_ROUNDS}, cap={Config.OASIS_MAX_ROUNDS_CAP})" + ) + total_rounds = effective_max + + # C3(denial-of-wallet 防护):限制 agent 数量。超过硬上限直接拒绝(避免巨额并发 LLM 调用)。 + agent_count = len(config.get("agent_configs", [])) + if agent_count > Config.OASIS_MAX_AGENTS_CAP: + raise ValueError( + f"Agent 数量 {agent_count} 超过上限 {Config.OASIS_MAX_AGENTS_CAP}," + f"请减少种子实体或调高 OASIS_MAX_AGENTS_CAP 环境变量" + ) + state = SimulationRunState( simulation_id=simulation_id, runner_status=RunnerStatus.STARTING, @@ -419,9 +432,9 @@ def start_simulation( "--config", config_path, # 使用完整配置文件路径 ] - # 如果指定了最大轮数,添加到命令行参数 - if max_rounds is not None and max_rounds > 0: - cmd.extend(["--max-rounds", str(max_rounds)]) + # C3:始终把已限制的有效轮数传给子进程,确保子进程按 default/cap 截断 + # (total_rounds 此处已应用 OASIS_DEFAULT_MAX_ROUNDS 与 OASIS_MAX_ROUNDS_CAP) + cmd.extend(["--max-rounds", str(total_rounds)]) # 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞 main_log_path = os.path.join(sim_dir, "simulation.log") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8c65b7294a..de9e2f45cc 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,7 +12,9 @@ dependencies = [ # 核心框架 "flask>=3.0.0", "flask-cors>=6.0.0", - + # 生产 WSGI 服务器(C1:替代 Werkzeug 开发服务器/调试器;须以 -w 1 --threads N 单进程启动) + "gunicorn>=21.0.0", + # LLM 相关 "openai>=1.0.0", diff --git a/backend/requirements.txt b/backend/requirements.txt index 4f146296ba..5853199f16 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,6 +8,9 @@ # ============= 核心框架 ============= flask>=3.0.0 flask-cors>=6.0.0 +# 生产 WSGI 服务器(C1:替代 Flask 自带的 Werkzeug 开发服务器/调试器) +# 注意:因模拟运行态保存在进程内类变量,必须以单 worker 多线程启动(-w 1 --threads N) +gunicorn>=21.0.0 # ============= LLM 相关 ============= # OpenAI SDK(统一使用 OpenAI 格式调用 LLM) diff --git a/backend/scripts/run_parallel_simulation.py b/backend/scripts/run_parallel_simulation.py index 2a627ffd04..3017c0b7e6 100644 --- a/backend/scripts/run_parallel_simulation.py +++ b/backend/scripts/run_parallel_simulation.py @@ -81,6 +81,11 @@ def _utf8_open(file, mode='r', buffering=-1, encoding=None, errors=None, _shutdown_event = None _cleanup_done = False +# C4:模拟超时(秒)。从环境变量读取(子进程继承 Flask 父进程的环境)。 +# 每轮 env.step 超时防止单轮因 LLM/网络挂起而永久 wedge;总超时为整轮模拟的硬墙钟上限。 +_ROUND_TIMEOUT_SEC = int(os.environ.get("OASIS_ROUND_TIMEOUT_SEC", "600")) +_RUN_TIMEOUT_SEC = int(os.environ.get("OASIS_RUN_TIMEOUT_SEC", "7200")) + # 添加 backend 目录到路径 # 脚本固定位于 backend/scripts/ 目录 _scripts_dir = os.path.dirname(os.path.abspath(__file__)) @@ -333,8 +338,10 @@ async def _interview_single_platform(self, agent_id: int, prompt: str, platform: action_args={"prompt": prompt} ) actions = {agent: interview_action} - await env.step(actions) - + # C4:采访 env.step 加超时;TimeoutError 会被本方法的 except Exception 捕获并返回错误响应, + # 不会让持久化命令循环永久卡死。 + await asyncio.wait_for(env.step(actions), timeout=_ROUND_TIMEOUT_SEC) + result = self._get_interview_result(agent_id, actual_platform) result["platform"] = actual_platform return result @@ -466,7 +473,7 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict], print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}") if twitter_actions: - await self.twitter_env.step(twitter_actions) + await asyncio.wait_for(self.twitter_env.step(twitter_actions), timeout=_ROUND_TIMEOUT_SEC) # C4:批量采访超时 for interview in twitter_interviews: agent_id = interview.get("agent_id") @@ -493,7 +500,7 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict], print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}") if reddit_actions: - await self.reddit_env.step(reddit_actions) + await asyncio.wait_for(self.reddit_env.step(reddit_actions), timeout=_ROUND_TIMEOUT_SEC) # C4:批量采访超时 for interview in reddit_interviews: agent_id = interview.get("agent_id") @@ -1203,8 +1210,12 @@ def log_info(msg): pass if initial_actions: - await result.env.step(initial_actions) - log_info(f"已发布 {len(initial_actions)} 条初始帖子") + # C4:初始帖子的 env.step 也加超时 —— 否则在进入受保护的主循环前就可能因 LLM/网络挂起而 wedge + try: + await asyncio.wait_for(result.env.step(initial_actions), timeout=_ROUND_TIMEOUT_SEC) + log_info(f"已发布 {len(initial_actions)} 条初始帖子") + except asyncio.TimeoutError: + log_info(f"[超时] 初始帖子 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过初始帖子继续") # 记录 round 0 结束 if action_logger: @@ -1250,8 +1261,20 @@ def log_info(msg): action_logger.log_round_end(round_num + 1, 0) continue + # C4:总时长上限 —— 超过则优雅停止(保留环境,后续仍可 close/interview) + if (datetime.now() - start_time).total_seconds() > _RUN_TIMEOUT_SEC: + log_info(f"[超时] 模拟总时长超过 {_RUN_TIMEOUT_SEC}s,在第 {round_num + 1} 轮停止") + break + actions = {agent: LLMAction() for _, agent in active_agents} - await result.env.step(actions) + # C4:每轮超时 —— 防止 env.step 因 LLM/网络挂起而永久 wedge + try: + await asyncio.wait_for(result.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) + except asyncio.TimeoutError: + log_info(f"[超时] 第 {round_num + 1} 轮 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过并停止循环") + if action_logger: + action_logger.log_round_end(round_num + 1, 0) + break # 从数据库获取实际执行的动作并记录 actual_actions, last_rowid = fetch_new_actions_from_db( @@ -1402,8 +1425,12 @@ def log_info(msg): pass if initial_actions: - await result.env.step(initial_actions) - log_info(f"已发布 {len(initial_actions)} 条初始帖子") + # C4:初始帖子的 env.step 也加超时 —— 否则在进入受保护的主循环前就可能因 LLM/网络挂起而 wedge + try: + await asyncio.wait_for(result.env.step(initial_actions), timeout=_ROUND_TIMEOUT_SEC) + log_info(f"已发布 {len(initial_actions)} 条初始帖子") + except asyncio.TimeoutError: + log_info(f"[超时] 初始帖子 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过初始帖子继续") # 记录 round 0 结束 if action_logger: @@ -1449,8 +1476,20 @@ def log_info(msg): action_logger.log_round_end(round_num + 1, 0) continue + # C4:总时长上限 —— 超过则优雅停止(保留环境,后续仍可 close/interview) + if (datetime.now() - start_time).total_seconds() > _RUN_TIMEOUT_SEC: + log_info(f"[超时] 模拟总时长超过 {_RUN_TIMEOUT_SEC}s,在第 {round_num + 1} 轮停止") + break + actions = {agent: LLMAction() for _, agent in active_agents} - await result.env.step(actions) + # C4:每轮超时 —— 防止 env.step 因 LLM/网络挂起而永久 wedge + try: + await asyncio.wait_for(result.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) + except asyncio.TimeoutError: + log_info(f"[超时] 第 {round_num + 1} 轮 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过并停止循环") + if action_logger: + action_logger.log_round_end(round_num + 1, 0) + break # 从数据库获取实际执行的动作并记录 actual_actions, last_rowid = fetch_new_actions_from_db( @@ -1577,16 +1616,34 @@ async def main(): reddit_result: Optional[PlatformSimulation] = None if args.twitter_only: - twitter_result = await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds) + # C4:与并行路径对称 —— 单平台非超时异常也隔离为 None 并记录,确保后续 env.close 块仍可达 + try: + twitter_result = await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds) + except Exception as e: + log_manager.error(f"[Twitter] 模拟异常,已隔离: {e}") + twitter_result = None elif args.reddit_only: - reddit_result = await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds) + try: + reddit_result = await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds) + except Exception as e: + log_manager.error(f"[Reddit] 模拟异常,已隔离: {e}") + reddit_result = None else: # 并行运行(每个平台使用独立的日志记录器) + # C4:return_exceptions=True —— 一个平台抛异常(如 LLM 401/429/配额)不再取消另一平台, + # 也不会绕过下方的 env.close(否则环境泄漏,违背“优雅停止、保留环境”的设计)。 results = await asyncio.gather( run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds), run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds), + return_exceptions=True, ) twitter_result, reddit_result = results + if isinstance(twitter_result, BaseException): + log_manager.error(f"[Twitter] 模拟异常,已隔离: {twitter_result}") + twitter_result = None + if isinstance(reddit_result, BaseException): + log_manager.error(f"[Reddit] 模拟异常,已隔离: {reddit_result}") + reddit_result = None total_elapsed = (datetime.now() - start_time).total_seconds() log_manager.info("=" * 60) diff --git a/backend/scripts/run_reddit_simulation.py b/backend/scripts/run_reddit_simulation.py index 14907cbda5..c5196082b4 100644 --- a/backend/scripts/run_reddit_simulation.py +++ b/backend/scripts/run_reddit_simulation.py @@ -29,6 +29,10 @@ _shutdown_event = None _cleanup_done = False +# C4:模拟超时(秒),从环境变量读取(子进程继承父进程环境) +_ROUND_TIMEOUT_SEC = int(os.environ.get("OASIS_ROUND_TIMEOUT_SEC", "600")) +_RUN_TIMEOUT_SEC = int(os.environ.get("OASIS_RUN_TIMEOUT_SEC", "7200")) + # 添加项目路径 _scripts_dir = os.path.dirname(os.path.abspath(__file__)) _backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..')) @@ -230,7 +234,7 @@ async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> # 执行Interview actions = {agent: interview_action} - await self.env.step(actions) + await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4:采访超时(TimeoutError 由本块 except 捕获) # 从数据库获取结果 result = self._get_interview_result(agent_id) @@ -276,7 +280,7 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) return False # 执行批量Interview - await self.env.step(actions) + await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4:采访超时(TimeoutError 由本块 except 捕获) # 获取所有结果 results = {} @@ -616,8 +620,12 @@ async def run(self, max_rounds: int = None): print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}") if initial_actions: - await self.env.step(initial_actions) - print(f" 已发布 {len(initial_actions)} 条初始帖子") + # C4:初始帖子的 env.step 也加超时,避免进入主循环前就因 LLM/网络挂起而 wedge + try: + await asyncio.wait_for(self.env.step(initial_actions), timeout=_ROUND_TIMEOUT_SEC) + print(f" 已发布 {len(initial_actions)} 条初始帖子") + except asyncio.TimeoutError: + print(f" [超时] 初始帖子 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过继续", flush=True) # 主模拟循环 print("\n开始模拟循环...") @@ -634,13 +642,23 @@ async def run(self, max_rounds: int = None): if not active_agents: continue - + + # C4:总时长上限 —— 超过则优雅停止 + if (datetime.now() - start_time).total_seconds() > _RUN_TIMEOUT_SEC: + print(f"[超时] 模拟总时长超过 {_RUN_TIMEOUT_SEC}s,在第 {round_num + 1} 轮停止", flush=True) + break + actions = { agent: LLMAction() for _, agent in active_agents } - - await self.env.step(actions) + + # C4:每轮超时 —— 防止 env.step 因 LLM/网络挂起而永久 wedge + try: + await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) + except asyncio.TimeoutError: + print(f"[超时] 第 {round_num + 1} 轮 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过并停止循环", flush=True) + break if (round_num + 1) % 10 == 0 or round_num == 0: elapsed = (datetime.now() - start_time).total_seconds() diff --git a/backend/scripts/run_twitter_simulation.py b/backend/scripts/run_twitter_simulation.py index caab9e9d35..48f5893745 100644 --- a/backend/scripts/run_twitter_simulation.py +++ b/backend/scripts/run_twitter_simulation.py @@ -29,6 +29,10 @@ _shutdown_event = None _cleanup_done = False +# C4:模拟超时(秒),从环境变量读取(子进程继承父进程环境) +_ROUND_TIMEOUT_SEC = int(os.environ.get("OASIS_ROUND_TIMEOUT_SEC", "600")) +_RUN_TIMEOUT_SEC = int(os.environ.get("OASIS_RUN_TIMEOUT_SEC", "7200")) + # 添加项目路径 _scripts_dir = os.path.dirname(os.path.abspath(__file__)) _backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..')) @@ -230,7 +234,7 @@ async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> # 执行Interview actions = {agent: interview_action} - await self.env.step(actions) + await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4:采访超时(TimeoutError 由本块 except 捕获) # 从数据库获取结果 result = self._get_interview_result(agent_id) @@ -276,7 +280,7 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) return False # 执行批量Interview - await self.env.step(actions) + await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4:采访超时(TimeoutError 由本块 except 捕获) # 获取所有结果 results = {} @@ -623,8 +627,12 @@ async def run(self, max_rounds: int = None): print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}") if initial_actions: - await self.env.step(initial_actions) - print(f" 已发布 {len(initial_actions)} 条初始帖子") + # C4:初始帖子的 env.step 也加超时,避免进入主循环前就因 LLM/网络挂起而 wedge + try: + await asyncio.wait_for(self.env.step(initial_actions), timeout=_ROUND_TIMEOUT_SEC) + print(f" 已发布 {len(initial_actions)} 条初始帖子") + except asyncio.TimeoutError: + print(f" [超时] 初始帖子 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过继续", flush=True) # 主模拟循环 print("\n开始模拟循环...") @@ -643,15 +651,25 @@ async def run(self, max_rounds: int = None): if not active_agents: continue - + + # C4:总时长上限 —— 超过则优雅停止 + if (datetime.now() - start_time).total_seconds() > _RUN_TIMEOUT_SEC: + print(f"[超时] 模拟总时长超过 {_RUN_TIMEOUT_SEC}s,在第 {round_num + 1} 轮停止", flush=True) + break + # 构建动作 actions = { agent: LLMAction() for _, agent in active_agents } - + # 执行动作 - await self.env.step(actions) + # C4:每轮超时 —— 防止 env.step 因 LLM/网络挂起而永久 wedge + try: + await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) + except asyncio.TimeoutError: + print(f"[超时] 第 {round_num + 1} 轮 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过并停止循环", flush=True) + break # 打印进度 if (round_num + 1) % 10 == 0 or round_num == 0: diff --git a/backend/uv.lock b/backend/uv.lock index 642dd9c363..1b3132fb3e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -511,6 +511,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" }, ] +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -994,6 +1006,7 @@ dependencies = [ { name = "charset-normalizer" }, { name = "flask" }, { name = "flask-cors" }, + { name = "gunicorn" }, { name = "openai" }, { name = "pydantic" }, { name = "pymupdf" }, @@ -1022,6 +1035,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = ">=3.0.0" }, { name = "flask", specifier = ">=3.0.0" }, { name = "flask-cors", specifier = ">=6.0.0" }, + { name = "gunicorn", specifier = ">=21.0.0" }, { name = "openai", specifier = ">=1.0.0" }, { name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" }, { name = "pydantic", specifier = ">=2.0.0" }, diff --git a/docker-compose.yml b/docker-compose.yml index 637f1dfaee..96a2168712 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,13 @@ services: image: ghcr.io/666ghj/mirofish:latest # 加速镜像(如拉取缓慢可替换上方地址) # image: ghcr.nju.edu.cn/666ghj/mirofish:latest + # C2:本地构建以把前端 API Key 烤进打包产物。`docker compose up --build` 会用根 .env 里的 + # VITE_API_KEY 作为 build-arg;若直接拉取上方预构建镜像(不 --build),其内置的 key 不可控, + # 此时请改用 AUTH_ENABLED=false(单机/内网)或自行构建带 VITE_API_KEY 的镜像。 + build: + context: . + args: + VITE_API_KEY: ${VITE_API_KEY:-} container_name: mirofish env_file: - .env diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000000..63e0a3b88f --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,9 @@ +# 前端构建期变量(Vite 读取,仅 VITE_ 前缀会暴露到客户端包) +# 本地前端构建:复制为 frontend/.env 并填写;Docker 构建则由 docker compose 经 build-arg 注入(见根 .env 的 VITE_API_KEY)。 + +# 必须等于后端 .env 的 API_KEY —— 打包后的 UI 会用它作为 X-API-Key 调用 /api/*。 +# ⚠️ 会被打进客户端包、可被任何访问者提取,不能当作多租户隔离手段(见 README 安全说明)。 +VITE_API_KEY= + +# 后端 API 基地址(默认走前端预览/开发服务器的 /api 代理到后端;如直连后端请填写完整地址) +# VITE_API_BASE_URL=http://localhost:5001 diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e840e1166a..d29f22521a 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -10,10 +10,19 @@ const service = axios.create({ } }) +// API Key(C2):后端默认对 /api/* 强制鉴权,前端需在每个请求带上 X-API-Key。 +// 注意:构建到客户端包里的 VITE_API_KEY 是【可被任何访问者从 JS 包中提取】的,因此对“公开部署” +// 它只能挡住不加载页面的脚本式滥用,不能当作多租户隔离手段。真正的多租户场景应改为会话登录鉴权, +// 或在网关处注入按用户签发的 token;单机/内网/网关后部署时此值足够。 +const API_KEY = import.meta.env.VITE_API_KEY || '' + // 请求拦截器 service.interceptors.request.use( config => { config.headers['Accept-Language'] = i18n.global.locale.value + if (API_KEY) { + config.headers['X-API-Key'] = API_KEY + } return config }, error => { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8f1e4c11b5..94505343be 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -21,5 +21,19 @@ export default defineConfig({ secure: false } } + }, + // C1:生产镜像用 `vite preview` 提供已构建的静态产物(而非开发服务器)。 + // preview 默认不套用 server.proxy,需单独声明,否则 /api 不被转发到后端。 + // host:'0.0.0.0' 必需:默认仅绑 localhost,在容器内会让 Docker 端口映射(3000:3000)无法触达。 + preview: { + host: '0.0.0.0', + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:5001', + changeOrigin: true, + secure: false + } + } } }) diff --git a/package.json b/package.json index 63ace21a99..b30de3541b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,10 @@ "dev": "concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\"", "backend": "cd backend && uv run python run.py", "frontend": "cd frontend && npm run dev", - "build": "cd frontend && npm run build" + "build": "cd frontend && npm run build", + "start": "concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend:prod\" \"npm run frontend:preview\"", + "backend:prod": "cd backend && uv run gunicorn -w 1 --threads 8 --timeout 120 -b 0.0.0.0:5001 'app:create_app()'", + "frontend:preview": "cd frontend && npm run preview" }, "devDependencies": { "concurrently": "^9.1.2" From 394638d426efb92ee42588bb97584b0820b0b6b3 Mon Sep 17 00:00:00 2001 From: Yo-LRK Date: Sat, 13 Jun 2026 17:32:36 +0700 Subject: [PATCH 2/4] security: address HIGH findings (stored XSS, wildcard CORS, prod log leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 — stored XSS via v-html / innerHTML of unsanitized LLM/agent/interview/report content: add DOMPurify; both renderMarkdown() now return DOMPurify.sanitize(html) (Step4Report.vue, Step5Interaction.vue), and the formatAnswer innerHTML sink is wrapped in DOMPurify.sanitize. All 8 HTML-injection sinks now sanitized; markdown rendering preserved (DOMPurify secure defaults keep the md-* tags/classes). H4 — wildcard CORS: CORS origins now Config.ALLOWED_ORIGINS (comma-separated env, default localhost:3000) instead of '*'. H5 — request bodies written to disk in cleartext: logger file level now follows FLASK_DEBUG (INFO in prod), so the before_request body-debug log is suppressed in production. (traceback-in-response across 53 handlers deferred to a separate refactor.) Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 ++++ backend/app/__init__.py | 4 ++-- backend/app/config.py | 8 ++++++++ backend/app/utils/logger.py | 16 ++++++++++----- frontend/package-lock.json | 21 ++++++++++++++++---- frontend/package.json | 1 + frontend/src/components/Step4Report.vue | 17 ++++++++++------ frontend/src/components/Step5Interaction.vue | 4 +++- 8 files changed, 57 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 5d90cd3217..2aa57f90b2 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,10 @@ API_KEY=change_me_to_a_strong_api_key # 使打包后的 UI 自动带上 X-API-Key。注意:它会被打进客户端包、可被任何访问者提取(见 README 安全说明)。 VITE_API_KEY=change_me_to_a_strong_api_key +# ===== CORS 允许来源(H4)===== +# 逗号分隔的前端来源;不再用通配 '*'。生产填前端域名,例如 https://app.example.com +ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + # ===== 模拟成本上限(C3,denial-of-wallet 防护)===== # 客户端未传 max_rounds 时的默认轮数上限(完整长度模拟请按请求传 max_rounds 或调高此值) # 默认 150 覆盖典型配置(72h/30min=144 轮)以免悄悄截断标准演示 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3954732620..c2e302291a 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -47,8 +47,8 @@ def create_app(config_class=Config): logger.info("MiroFish Backend 启动中...") logger.info("=" * 50) - # 启用CORS - CORS(app, resources={r"/api/*": {"origins": "*"}}) + # 启用CORS(H4):限定来源为 Config.ALLOWED_ORIGINS(默认本地前端源),不再通配 '*'。 + CORS(app, resources={r"/api/*": {"origins": Config.ALLOWED_ORIGINS}}) # API Key 鉴权(C2):所有 /api/* 端点强制鉴权。 # 客户端通过 `X-API-Key: ` 或 `Authorization: Bearer ` 传入。 diff --git a/backend/app/config.py b/backend/app/config.py index 0a6b90dd63..5c27a3a991 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -34,6 +34,14 @@ class Config: API_KEY = os.environ.get('API_KEY') AUTH_ENABLED = os.environ.get('AUTH_ENABLED', 'true').strip().lower() not in ('false', '0', 'no', 'off') + # CORS 允许来源(H4):不再用通配 '*'。默认仅本地前端开发/预览源;生产用逗号分隔的 + # ALLOWED_ORIGINS 指定前端域名(例如 https://app.example.com)。'*' 仍可显式设置但不推荐。 + ALLOWED_ORIGINS = [ + o.strip() for o in os.environ.get( + 'ALLOWED_ORIGINS', 'http://localhost:3000,http://127.0.0.1:3000' + ).split(',') if o.strip() + ] + # JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式) JSON_AS_ASCII = False diff --git a/backend/app/utils/logger.py b/backend/app/utils/logger.py index 93422afafb..ee4e7d1263 100644 --- a/backend/app/utils/logger.py +++ b/backend/app/utils/logger.py @@ -27,17 +27,23 @@ def _ensure_utf8_stdout(): LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs') -def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.Logger: +def setup_logger(name: str = 'mirofish', level: int = None) -> logging.Logger: """ 设置日志器 - + Args: name: 日志器名称 - level: 日志级别 - + level: 日志级别(None 时按 FLASK_DEBUG 自动选择) + Returns: 配置好的日志器 """ + # H5:生产模式(FLASK_DEBUG=false)下用 INFO —— 避免把请求体/调试细节(可能含上传内容、 + # 提示词、客户端传入的凭据)以明文写入轮转日志文件。开发模式仍用 DEBUG。 + if level is None: + from ..config import Config + level = logging.DEBUG if Config.DEBUG else logging.INFO + # 确保日志目录存在 os.makedirs(LOG_DIR, exist_ok=True) @@ -71,7 +77,7 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging. backupCount=5, encoding='utf-8' ) - file_handler.setLevel(logging.DEBUG) + file_handler.setLevel(level) file_handler.setFormatter(detailed_formatter) # 2. 控制台处理器 - 简洁日志(INFO及以上) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3e56d752df..7570ce3873 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.14.0", "d3": "^7.9.0", + "dompurify": "^3.4.10", "vue": "^3.5.24", "vue-i18n": "^11.3.0", "vue-router": "^4.6.3" @@ -938,6 +939,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz", @@ -1435,7 +1443,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1538,6 +1545,15 @@ "node": ">=0.4.0" } }, + "node_modules/dompurify": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", + "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1913,7 +1929,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2053,7 +2068,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2128,7 +2142,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", diff --git a/frontend/package.json b/frontend/package.json index 1501b628f9..65bcdacbac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "axios": "^1.14.0", "d3": "^7.9.0", + "dompurify": "^3.4.10", "vue": "^3.5.24", "vue-i18n": "^11.3.0", "vue-router": "^4.6.3" diff --git a/frontend/src/components/Step4Report.vue b/frontend/src/components/Step4Report.vue index 8e53ceb53b..402092fb9d 100644 --- a/frontend/src/components/Step4Report.vue +++ b/frontend/src/components/Step4Report.vue @@ -391,6 +391,7 @@