# 安全加固总结(前端 / 后端 / 算力节点) > 记录 2026-08-06 对本平台的漏洞修复。核心目标:修复 **FastAPI 文档接口未授权访问**、 > **Swagger 泄露 API 结构**、以及两类**任意文件读取**漏洞(路径穿越 + 符号链接跟随), > 修复过程不改变正常业务流程。 > > 其中 **FastAPI 文档开关(`ENABLE_DOCS`)** 的详细用法见 > [§4 FastAPI 文档开关使用说明](#4-fastapi-文档开关使用说明enabledocs)。 --- ## 1. 漏洞总览 | # | 影响面 | 漏洞 | 风险等级 | 修复 | |---|--------|------|----------|------| | 1 | 后端 + 算力节点 | FastAPI 默认暴露 `/docs`、`/redoc`、`/openapi.json`,**未授权**泄露全部 API 结构、参数、内部路由 | 中 | 生产环境关闭文档路由,访问返回 404(`ENABLE_DOCS` 可覆盖) | | 2 | 后端 | `data-convert` 模块 `output_filename` **路径穿越**:可任意文件读 / 写 / 删,且整个模块**无鉴权** | **严重** | 输出文件名白名单校验 + 全部端点补鉴权 | | 3 | 算力节点 | `compute/files/{file_id}/download`:`file_id` 直接拼进 glob 模式可 `../` **穿越出上传目录**,`FileResponse` 在 Linux 上**跟随符号链接**读取任意文件 | 中高 | `file_id` 字符白名单 + 解析后路径包含性二次校验 | | 4 | 前端(Vue + nginx) | 无文件服务代码;nginx 仅服务受控静态目录,无 `alias` | 无 | 审计确认,无需修复 | --- ## 2. 前端(Vue 3 + nginx) **审计结论:不构成 ComfyUI `follow_symlinks` 类文件读取漏洞。** - nginx(`docker/nginx.conf.template`)只服务受控的 `dist/` 静态目录,使用 `try_files`, 无 `alias` 指令、无用户可控文件路径,不存在路径穿越面。 - Vue SPA 自身没有任何文件服务逻辑;文件下载全部走后端/算力节点 API。 - 前端 axios 拦截器(`frontend/src/api/request.ts`)对**每个请求**自动附加 `Authorization: Bearer platform-token-{user_id}`,因此给后端接口补鉴权不会影响页面功能。 --- ## 3. 后端(FastAPI) ### 3.1 data_convert 输出文件名路径穿越(严重) **问题**:`backend/app/modules/data_convert/router.py` - `output_filename` 由请求体传入后**原样入库**,随后拼进 `output_dir / output_filename` 用于写/读/删: - `if output_path.exists(): output_path.unlink()` → 任意文件删除 - `open(output_path, "a")` → 任意文件追加写 - `download_result` 用 `FileResponse(output_path)` → 任意文件读取 - 整个 router **无任何鉴权依赖**(后端无全局鉴权中间件),任意网络访问者可利用。 **修复**: ```python def _safe_output_filename(value: Any) -> str: """输出文件名白名单:拒绝 ../、/、\ 及控制字符,仅允许普通文件名。""" name = str(value or "converted-data.jsonl").strip() if ( not name or name in {".", ".."} or name != Path(name).name or "/" in name or "\\" in name or any(ord(c) < 32 or ord(c) == 127 for c in name) ): raise fail(400, "output filename must be a plain file name") return name def _task_output_path(task: dict[str, Any]) -> Path: """统一构造转换输出路径,始终位于任务 output 目录内。""" return _output_dir(task["id"]) / _safe_output_filename(task.get("output_filename")) ``` - `create_task` 创建时即校验(恶意值直接 400) - 全部 4 处使用点(`upload_source_files` 自动转换、`run_convert`、`download_result`、 `import_as_dataset`)统一改用 `_task_output_path()`,历史任务同样受保护 - 全部 **8 个** `/data-convert` 端点补充 `current_user: dict = Depends(get_current_user)` 鉴权 **功能影响**:正常转换流程(前端 `outputName + '.jsonl'` 这类纯文件名)不受影响; 接口现在要求登录态,未登录调用返回 401。 --- ## 4. FastAPI 文档开关使用说明(`ENABLE_DOCS`) ### 4.1 为什么需要这个开关 FastAPI 默认注册 3 个**无需鉴权**的路由,直接泄露全部 API 结构: | 路由 | 说明 | |------|------| | `/docs` | Swagger UI 交互文档 | | `/redoc` | ReDoc 文档 | | `/openapi.json` | OpenAPI Schema(含全部接口、参数、模型定义) | 修复方式是:**关闭时让 FastAPI 不注册这 3 个路由**,访问一律返回 404,而不是返回空页面。 ### 4.2 核心实现 关闭的本质是向 `FastAPI(...)` 传入三个 `None` 参数: ```python # docs 关闭时等价于: FastAPI( title=..., docs_url=None, # /docs → 404 redoc_url=None, # /redoc → 404 openapi_url=None, # /openapi.json → 404 ) ``` ### 4.3 后端开关逻辑(`backend/app/core/config.py` + `backend/app/main.py`) ```python # config.py —— Settings.enable_docs 在 __post_init__ 中计算 object.__setattr__( self, "enable_docs", _bool_env("ENABLE_DOCS", os.getenv("APP_ENV", "local") != "prod"), ) # config.py —— 返回传给 FastAPI 的文档参数 def docs_kwargs(enabled: bool) -> dict[str, Any]: if enabled: return {} return {"docs_url": None, "redoc_url": None, "openapi_url": None} # main.py —— 接入 app = FastAPI(title=settings.app_name, **docs_kwargs(settings.enable_docs)) ``` **判定顺序(优先级从高到低)**: 1. 显式设置 `ENABLE_DOCS=true/false` → 以显式值为准 2. 未设置 → `APP_ENV != "prod"` 时开放,`APP_ENV=prod` 时**关闭** > 注意:`enable_docs` 从**运行时环境**读取 `APP_ENV`(而非类定义时缓存的默认值), > 确保生产环境默认关闭始终生效且便于测试。 ### 4.4 算力节点开关逻辑(`compute/api/security.py` + `compute/api/main.py`) ```python # security.py def docs_enabled() -> bool: raw = os.getenv("ENABLE_DOCS", "").strip().lower() if raw in {"true", "false"}: return raw == "true" auth_enabled = os.getenv("COMPUTE_AUTH_ENABLED", "true").lower() == "true" return not auth_enabled # 开启 token 鉴权(生产)时默认关闭文档 def docs_kwargs() -> dict[str, Any]: if docs_enabled(): return {} return {"docs_url": None, "redoc_url": None, "openapi_url": None} # main.py app = FastAPI(title="YG Zhilian Compute API", **docs_kwargs()) ``` **判定顺序(优先级从高到低)**: 1. 显式设置 `ENABLE_DOCS=true/false` → 以显式值为准 2. 未设置 → `COMPUTE_AUTH_ENABLED=true`(生产默认)时**关闭**; `COMPUTE_AUTH_ENABLED=false`(本地开发)时开放 ### 4.5 环境变量速查表 | 服务 | 环境变量 | 取值 | 默认行为 | |------|----------|------|----------| | 后端 | `ENABLE_DOCS` | `true` / `false` | 未设置时按 `APP_ENV != "prod"` 判定 | | 后端 | `APP_ENV` | `local` / `prod` 等 | `prod` 时关闭文档 | | 算力节点 | `ENABLE_DOCS` | `true` / `false` | 未设置时按 `COMPUTE_AUTH_ENABLED` 判定 | | 算力节点 | `COMPUTE_AUTH_ENABLED` | `true` / `false` | `true` 时关闭文档 | ### 4.6 Docker 部署配置 已在以下文件加入 `ENABLE_DOCS=false`,并通过 docker-compose 透传(默认 `false`): ``` docker/app/.env → ENABLE_DOCS=false docker/compute/.env → ENABLE_DOCS=false docker/compute/.env.example → ENABLE_DOCS=false docker/app/docker-compose.yml → ENABLE_DOCS: ${ENABLE_DOCS:-false} docker/compute/docker-compose.yml → ENABLE_DOCS: ${ENABLE_DOCS:-false} ``` ### 4.7 如何临时开启(排查/调试) ```bash # 后端:非 prod 环境默认已开启;prod 环境临时开启 ENABLE_DOCS=true docker compose -f docker/app/docker-compose.yml up -d backend-api # 算力节点:临时开启(生产默认关闭) ENABLE_DOCS=true docker compose -f docker/compute/docker-compose.yml up -d compute-api ``` > ⚠️ 仅在可信内网调试时开启,用毕改回 `false`。 ### 4.8 验证方法 ```bash # 关闭状态下三个地址均应返回 404 curl -s -o /dev/null -w "%{http_code}\n" http:///docs # 404 curl -s -o /dev/null -w "%{http_code}\n" http:///redoc # 404 curl -s -o /dev/null -w "%{http_code}\n" http:///openapi.json # 404 # 健康检查不受影响 curl -s http:///modelTF/health ``` --- ## 5. 算力节点(FastAPI) ### 5.1 文档开关 见 [§4.4](#44-算力节点开关逻辑computeapisecuritypy--computeapimainpy),逻辑与后端一致, 生产(`COMPUTE_AUTH_ENABLED=true`)默认关闭。 > 补充:原 token 鉴权中间件已覆盖全部非 health 路径;现在文档路由同时被 FastAPI 层关闭, > 属于**纵深防御**(双重保护)。 ### 5.2 `download_file` glob 穿越 + 符号链接跟随(`compute/api/main.py`) **问题**: ```python matches = list(upload_root.glob(f"{file_id}_*")) # file_id 来自 URL,直接拼进 glob return FileResponse(matches[0]) # 跟随符号链接 ``` - `Path.glob` 支持 `..` 段,`file_id` 注入 `../` 可**穿越出 upload 目录**(已实测确认) - Linux 上目录内符号链接可被 `FileResponse` 跟随 → 读取任意文件 **修复**: ```python # 1) file_id 字符白名单:仅字母/数字/_/-,含 .、/、% 等一律 400 if not file_id or not all(c.isalnum() or c in {"_", "-"} for c in file_id): raise HTTPException(status_code=400, detail="invalid file id") matches = list(upload_root.glob(f"{file_id}_*")) if not matches: raise HTTPException(status_code=404, detail="file not found") # 2) 解析符号链接后必须仍位于 upload 根目录内 resolved = matches[0].resolve() if not _path_inside(upload_root, resolved): raise HTTPException(status_code=404, detail="file not found") return FileResponse(resolved) ``` **功能影响**:服务端生成的 `file_<时间戳>` 格式完全兼容;外部工具使用正常 `file_id` 下载不受影响。 ### 5.3 已确认安全的同类文件端点(无需改动) | 端点 | 保护机制 | |------|----------| | `compute/files/list` | `_path_inside()` + `.resolve()`,符号链接逃逸被阻断 | | `compute/files/read` | 同上 | | `compute/files/upload` | 同上 + 文件名取 `.name` | | `compute/files/import-local` | 目标路径 `_path_inside()` 校验 | | 后端 `data-process` 存储 `LocalDataProcessStorage` | `lstat` + `S_ISLNK` + `O_NOFOLLOW` + 规范化引用校验,彻底防符号链接 | --- ## 6. 测试与验证结果 | 验证项 | 结果 | |--------|------| | 计算节点全量测试 | **22 passed, 1 skipped**(跳过项为 Windows 无权限建符号链接,Linux 生产环境会执行) | | 新增 compute 下载安全测试 | 正常下载 200;穿越样本 400/404;符号链接逃逸 404 | | 新增后端 data_convert 安全测试 | **13 passed**(穿越样本 9 项全拦截 + 鉴权覆盖检查) | | 后端文档开关测试 | 6 passed(2 项 `create_app` 集成测试需完整依赖,在 WSL 下运行) | | 实时验证 | 生产环境 `/docs` `/redoc` `/openapi.json` 均返回 **404**;`download_file` 合法 `file_123456` 返回 200 | --- ## 7. 变更文件清单 **后端** - `backend/app/core/config.py` — 新增 `_bool_env`、`docs_kwargs()`、`Settings.enable_docs` - `backend/app/main.py` — `FastAPI(...)` 接入 `docs_kwargs` - `backend/app/modules/data_convert/router.py` — 输出文件名白名单 + 全部端点补鉴权 **算力节点** - `compute/api/security.py` — 新增文档开关模块(`docs_enabled` / `docs_kwargs`) - `compute/api/main.py` — 文档开关接入 + `download_file` 加固 **Docker 配置** - `docker/app/.env`、`docker/compute/.env`、`docker/compute/.env.example` — `ENABLE_DOCS=false` - `docker/app/docker-compose.yml`、`docker/compute/docker-compose.yml` — 透传 `ENABLE_DOCS` **测试** - `backend/tests/test_docs_security.py`、`backend/tests/test_data_convert_security.py` - `compute/tests/test_security.py`、`compute/tests/test_file_download_security.py`