# 安全加固总结（前端 / 后端 / 算力节点）

> 记录 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 Fine-Tune 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`
