291 lines
12 KiB
Markdown
291 lines
12 KiB
Markdown
# 安全加固总结(前端 / 后端 / 算力节点)
|
||
|
||
> 记录 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://<host>/docs # 404
|
||
curl -s -o /dev/null -w "%{http_code}\n" http://<host>/redoc # 404
|
||
curl -s -o /dev/null -w "%{http_code}\n" http://<host>/openapi.json # 404
|
||
|
||
# 健康检查不受影响
|
||
curl -s http://<host>/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`
|