Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,36 @@ 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
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: <API_KEY>`(或 `Authorization: Bearer <API_KEY>`)
# 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

# ===== 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 轮)以免悄悄截断标准演示
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
14 changes: 12 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
# C1:生产启动 —— 后端用 gunicorn(单 worker 多线程,保留进程内模拟态),
# 前端用 vite preview 提供已构建产物。开发请改用 `npm run dev`。
# 安全前提:须经 .env 设置 SECRET_KEY 与 API_KEY(FLASK_DEBUG 默认 false)。
CMD ["npm", "run", "start"]
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,48 @@ 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: <API_KEY> (or Authorization: Bearer <API_KEY> ).
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.
- **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

```bash
Expand Down
60 changes: 54 additions & 6 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
MiroFish Backend - Flask应用工厂
"""

import hmac
import os
import warnings

# 抑制 multiprocessing resource_tracker 的警告(来自第三方库如 transformers)
# 需要在所有其他导入之前设置
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
Expand All @@ -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'):
Expand All @@ -39,9 +47,40 @@ 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: <key>` 或 `Authorization: Bearer <key>` 传入。
# /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()
Expand All @@ -61,7 +100,16 @@ def log_response(response):
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')
Expand Down
29 changes: 15 additions & 14 deletions backend/app/api/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""

import os
import traceback
from ..utils.security import safe_traceback, safe_error, upload_content_ok
import threading
from flask import request, jsonify

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -250,8 +251,8 @@ def generate_ontology():
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
"error": safe_error(e),
"traceback": safe_traceback()
}), 500


Expand Down Expand Up @@ -495,17 +496,17 @@ def wait_progress_callback(msg, progress_ratio):
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)
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)),
error=traceback.format_exc()
message=t('progress.buildFailed', error=safe_error(e)),
error=safe_traceback()
)

# 启动后台线程
Expand All @@ -524,8 +525,8 @@ def wait_progress_callback(msg, progress_ratio):
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
"error": safe_error(e),
"traceback": safe_traceback()
}), 500


Expand Down Expand Up @@ -589,8 +590,8 @@ def get_graph_data(graph_id: str):
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
"error": safe_error(e),
"traceback": safe_traceback()
}), 500


Expand All @@ -617,6 +618,6 @@ def delete_graph(graph_id: str):
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
"error": safe_error(e),
"traceback": safe_traceback()
}), 500
Loading