Files
YG_FT/docs/security-hardening.md

291 lines
12 KiB
Markdown
Raw Permalink Normal View History

# 安全加固总结(前端 / 后端 / 算力节点)
> 记录 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 passed2 项 `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`