chore: 转移前备份 — cleanup + project book

- 删 VULNERABILITY_REPORT.md(已审完,合并到 commit 历史)
- 删 middleware/ratelimit_test.go(单测延后)
- .gitignore 加 *_test.go 规则(防误提交)
- README XFF 验证段措辞更新
- 新增 PROJECT_BOOK.md(v0.1 待评审)

基线备份:目录迁移到非云盘路径前的快照。
This commit is contained in:
sun
2026-08-29 18:40:36 +08:00
parent 8be40ae6dc
commit dc3248d11c
5 changed files with 395 additions and 427 deletions
+3
View File
@@ -4,6 +4,9 @@
*.test *.test
*.out *.out
# Go test sources (prevent accidental commit; tests live outside the repo by policy)
*_test.go
# Editor / OS # Editor / OS
.vscode/ .vscode/
.idea/ .idea/
+391
View File
@@ -0,0 +1,391 @@
# 火山 TTS 聚合平台 · 项目书(v0.1)
> 定位:个人自用(无甲方)· 单机部署 · 单二进制分发
> 基线代码:Volcano-Engine-TTS-UI(develop 分支,Go,约 2900 行)
---
## 1. 项目概述
### 1.1 背景与动机
现有 `Volcano-Engine-TTS-UI` 是一个"火山引擎 TTS v3 → OpenAI 兼容接口"的适配器,已具备 `/v1/audio/speech`、多格式输出、鉴权限流、Prometheus 观测等能力。当前存在三个核心问题:
1. **配置全部堆在环境变量里**:API Key、资源 ID、音色、采样率……随音色增长会持续"溢出",无法维护。
2. **音色是静态的**:`voice`/`model` 请求字段被接收但忽略,只支持 env 里配死的单音色。
3. **首次启动没有入口**:无 UI、无数据库,配置只能靠手写 env。
### 1.2 项目目标
把该项目升级为**自用聚合平台**:
- 用 **SQLite** 接管所有运行时可变配置(全局设置 + 音色库),环境变量只保留 3 个左右引导参数;
- 提供**引导式安装 UI**:首次启动(未安装)进入 `/setup` 向导收集配置,写入数据库后即完成安装;
- 采用 **lock 文件**作为安装状态判据,支持"损坏自动回退、可重置安装";
- `/v1/audio/speech` 的 `voice` 参数**按数据库路由**,实现多音色动态切换;
- 保持**单二进制分发**(SQLite 用纯 Go 驱动,`//go:embed` 嵌入引导页)。
### 1.3 设计原则
| 原则 | 说明 |
|---|---|
| 数据库为唯一配置源 | 运行时的全局参数、音色全部读库,不做"库 + env 双轨" |
| 环境变量只做引导 | 仅保留 DB 路径、端口、初始化凭证 |
| lock 是安装门卫 | 存在 = 已安装;不存在 = 安装模式;损坏 = 备份 + 删 lock + 回退 |
| 损坏可自愈 | 库异常自动备份留档并回退安装模式,不裸奔 |
| 先内核后 UI | 本期只做"引导页"(安装必需),完整管理后台后置 |
---
## 2. 需求范围
### 2.1 本期(MVP)范围
| 编号 | 需求 | 说明 |
|---|---|---|
| R1 | SQLite 接入 | `modernc.org/sqlite`(纯 Go、无 CGO),自动建表、轻量迁移 |
| R2 | 全局配置入库 | `settings` 表接管现有全部 TTS 全局环境变量 |
| R3 | 音色库 | `voices` 表:name → speaker/resource_id/model/…,支持增删改查 |
| R4 | 安装状态检测 | `installed.lock` 判据 + 启动判定流程 |
| R5 | 引导式安装 UI | `/setup` 引导页(首次启动数据收集)+ `POST /api/setup` |
| R6 | 安装模式路由守卫 | 未安装时全站只开放 `/setup`,其余返回 503/跳转 |
| R7 | voice 动态路由 | `/v1/audio/speech` 按 `voice` 查库路由到火山 |
| R8 | 音色管理 API | `GET/POST /api/voices`、`PUT/DELETE /api/voices/:id`(带鉴权) |
| R9 | 损坏回退 | 库校验失败 → 备份 `.corrupt-<ts>` → 删 lock → 重新安装 |
| R10 | 环境变量收敛 | 迁移后 env 仅剩:`TTS_DB_PATH`、`PORT`、初始化凭证 |
### 2.2 后置(不在本期)
- 完整管理后台(音色列表/用量图表/配置管理)
- 多服务商聚合(火山 / OpenAI / 微软统一适配)
- 流式实时输出(SSE/WebSocket)
- TTS 结果缓存、长文本自动分片、字幕透出
- ASR 转写端点(`/v1/audio/transcriptions`)
---
## 3. 总体架构
```
┌───────────────────────────── 单二进制 tts-api ─────────────────────────────┐
│ │
│ main.go ── 启动:初始化 DB → 安装状态检测 → 加载配置 → 路由 → 监听 │
│ │
│ ┌────────────┐ ┌─────────────┐ ┌────────────────────────────────────┐ │
│ │ installer/ │──▶│ store/ │ │ router/ │ │
│ │ lock 检测 │ │ SQLite 访问 │ │ /setup(引导页·embed) /api/setup │ │
│ │ 安装模式 │ │ settings │ │ /api/voices* /v1/audio/speech│ │
│ └────────────┘ │ voices │ │ /health /metrics /dashboard │ │
│ └─────────────┘ └────────────────────────────────────┘ │
│ │
│ controller/ ── 语音合成(查库路由) · setup · voices CRUD │
│ middleware/ ── 鉴权 · 限流 · 并发 · 安装守卫 │
│ setting/ ── 仅保留引导参数(DB路径/端口/凭证) + 启动汇总 │
│ adapter/volcano/ ── 火山 v3 客户端(不改,仅入参来源变为 DB) │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## 4. 技术选型
| 项 | 选型 | 理由 |
|---|---|---|
| 语言 | Go(沿用) | 现有项目基线,无迁移成本 |
| 数据库 | SQLite(`modernc.org/sqlite`) | 纯 Go 无 CGO,保持 `CGO_ENABLED=0` 单二进制;单文件零运维 |
| 前端 | Vue3 + axios(沿用 dashboard 技术栈) | 复用现有 `//go:embed` 模式,`setup.html` 嵌入二进制 |
| 路由 | gorilla/mux(沿用) | 现有实现 |
| 构建 | 保持单二进制 | `//go:embed` 内嵌引导页与监控页 |
> 依赖注意:`modernc.org/sqlite` 体积较大(约 30-40MB 二进制),如介意可换 `mattn/go-sqlite3`(需 CGO,破坏单二进制),**本项目选前者**。
---
## 5. 数据模型设计
### 5.1 `settings` 表(全局配置)
```sql
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, -- 配置键名
value TEXT NOT NULL, -- 配置值(统一存字符串,读取时按需转换)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
初始化后必填的键:
| key | 说明 | 来源(原环境变量) |
|---|---|---|
| `api_key` | 火山 API Key | `BYTEDANCE_TTS_API_KEY` |
| `default_resource_id` | 默认资源 ID | `BYTEDANCE_TTS_RESOURCE_ID` |
| `default_speaker` | 默认音色 | `BYTEDANCE_TTS_SPEAKER` |
| `initialized` | 安装完成标记 `"1"` | —(双保险,配合 lock) |
可选键(读取时带默认值):
| key | 默认值 | 来源 |
|---|---|---|
| `default_format` | `mp3` | `BYTEDANCE_TTS_FORMAT` |
| `sample_rate` | `24000` | `BYTEDANCE_TTS_SAMPLE_RATE` |
| `timeout` | `30s` | `BYTEDANCE_TTS_TIMEOUT` |
| `model` | 空 | `BYTEDANCE_TTS_MODEL` |
| `model_type` | 空 | `BYTEDANCE_TTS_MODEL_TYPE` |
| `explicit_language` | 空 | `BYTEDANCE_TTS_EXPLICIT_LANGUAGE` |
| `enable_subtitle` | `false` | `BYTEDANCE_TTS_ENABLE_SUBTITLE` |
> 迁移建议:首次安装时若检测到旧的对应环境变量仍存在,可作为引导页**预填默认值**(仅预填,不替代库),便于老用户平滑迁移。
### 5.2 `voices` 表(音色库)
```sql
CREATE TABLE IF NOT EXISTS voices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- 对外 voice 名,请求里 voice 字段查它
speaker TEXT NOT NULL, -- 火山音色 ID(复刻音色以 S_ 开头)
resource_id TEXT NOT NULL, -- 对应火山资源 ID(决定计费/模型族)
model TEXT DEFAULT '', -- 子模型 seed-tts-2.0-standard / -expressive
language TEXT DEFAULT '', -- 显式语种(可选)
description TEXT DEFAULT '', -- 备注
enabled INTEGER NOT NULL DEFAULT 1, -- 0/1 启用
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_voices_name ON voices(name);
```
> 默认音色:`settings.default_speaker` 表示请求**未传 voice** 时用的音色;也可以约定默认 voice 名为 `default` 的行,二选一,建议用 settings 字段(更直观)。
---
## 6. 安装与启动流程设计
### 6.1 lock 判据
- lock 文件:`<DB目录>/installed.lock`(与 `tts.db` 同目录)。
- **存在 = 已成功完成过安装**;不存在 = 未安装。
- lock 内容:一行文本 `version <版本号> <初始化时间>`,便于未来判断是否需要重装/迁移。
- 写入时机:**先写库、后写 lock**(`/api/setup` 成功后、且通过完整性校验后才原子创建 lock),避免"lock 在、库是半成品"。
- **不用 `.db` 文件是否存在作为安装判据**(建表会自动创建 db 文件,无法区分"从未安装"和"已安装")。
### 6.2 启动判定流程
```
main 启动
├─ 解析引导环境变量(TTS_DB_PATH / PORT / 初始化凭证)
├─ store.Open()(打开/创建 SQLite,自动建表)
│
├─ installed.lock 不存在?
│ └─ YES → 进入【安装模式】:仅开放 /setup(静态资源 + 引导页 + POST /api/setup)
│ 其余路由(/v1/audio/speech、/api/voices 等)→ 503 + 跳转 /setup
│ └─ NO → 尝试读库 + PRAGMA integrity_check
│ ├─ 通过 → 加载 settings/voices 到内存缓存 → 【正常模式】
│ └─ 失败 → 备份 tts.db → tts.db.corrupt-<时间戳>
│ → 删除 installed.lock → 进入【安装模式】
│
└─ 打印启动摘要(模式 / DB路径 / lock状态 / 音色数)
```
### 6.3 损坏回退规则
1. 只在 lock 存在但库打不开 / `integrity_check` 失败时触发回退;
2. **先备份**:损坏的 `.db` 改名 `tts.db.corrupt-<ts>` 留档,不直接删除;
3. 删除 `installed.lock`,进入安装模式;
4. 日志明确打印回退原因(文件损坏 / 权限 / 磁盘 / 版本等),便于排查;
5. 回退后用户重新走 `/setup` 即可恢复。
### 6.4 setup 劫持防护(必须)
安装模式是"谁先访问谁配置",公网暴露时存在被抢先初始化的风险。防护方案(至少一项):
- **A(推荐)初始化凭证**:启动时打印一次性 setup token(或从环境变量 `TTS_ADMIN_KEY` 指定),引导页提交时必须带 token,校验通过才写入;
- **B 回环限制**:安装模式下 `/setup` 仅允许本机回环地址访问(`127.0.0.1`),初始化完成后即失效;
- 二者可叠加。初始化完成后 `/api/setup` 永久关闭。
---
## 7. 接口设计
### 7.1 安装相关
| 方法 | 路径 | 说明 | 鉴权 |
|---|---|---|---|
| GET | `/setup` | 引导页 HTML(`//go:embed`) | 安装模式开放 |
| GET | `/api/setup/status` | 返回 `{installed: bool}`,供引导页判断 | 无 |
| POST | `/api/setup` | 提交初始配置(全局 + 音色列表 + token)→ 写库 → 写 lock | 初始化凭证 |
`POST /api/setup` 请求体示例:
```json
{
"token": "一次性凭证",
"settings": {
"api_key": "xxx",
"default_resource_id": "volc.megatts.default",
"default_speaker": "zh_female_qingxin",
"default_format": "mp3",
"sample_rate": 24000
},
"voices": [
{ "name": "qian", "speaker": "S_xxx", "resource_id": "volc.megatts.icl", "model": "seed-tts-2.0-standard" },
{ "name": "xun", "speaker": "S_yyy", "resource_id": "volc.megatts.icl", "model": "seed-tts-2.0-expressive" }
]
}
```
响应:`200 {ok:true, message:"installed"}` 或 `400/401/409`。
### 7.2 音色管理(正常模式)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/voices` | 列表(可分页/过滤 enabled) |
| POST | `/api/voices` | 新增音色(name 唯一冲突返回 409) |
| PUT | `/api/voices/:id` | 更新音色 |
| DELETE | `/api/voices/:id` | 删除音色(`default_speaker` 引用的音色禁止删除) |
鉴权:复用现有 `OPENAI_TTS_API_KEY`(Bearer)。若安装后用户未配置管理 Key,可提示在 settings 中配置。
### 7.3 语音合成(改造点)
`/v1/audio/speech` 改造逻辑:
```
收到请求
├─ voice 为空 → 用 settings.default_speaker(无默认 → 400 "no default voice")
├─ voice 非空 → 查 voices 表
│ ├─ 命中 → 用该行 speaker/resource_id/model/language 覆盖 opts
│ └─ 未命中 → 400 "unknown voice: <name>"
├─ model 非空 → 查库/映射(本期:model 仅校验长度,不做映射,或按 settings 默认)
└─ 其余逻辑不变(格式解析、speed、鉴权、限流、埋点)
```
### 7.4 现有端点(不变)
`/health`、`/metrics`、`/dashboard`、`/` 行为保持;但**安装模式下**除 `/setup` 外统一返回 503(`/health` 可返回 `installed:false` 便于部署探针识别未初始化)。
---
## 8. 模块划分与代码改动清单
### 8.1 新增包
| 包 | 职责 | 关键文件 |
|---|---|---|
| `store/` | SQLite 访问层:打开/建表/迁移、settings CRUD、voices CRUD、integrity_check | `db.go`、`settings.go`、`voices.go`、`migrate.go` |
| `installer/` | 安装状态:lock 检测/创建/删除、安装模式判定、损坏回退 | `lock.go`、`bootstrap.go` |
### 8.2 改造文件
| 文件 | 改动 |
|---|---|
| `router/router.go` | 新增 `/setup`、`/api/setup/*`、`/api/voices`;安装模式路由守卫 |
| `controller/` | 新增 `setup.go`(安装提交)、`voices.go`(CRUD);改造 `tts.go`(voice 查库路由) |
| `middleware/` | 新增 `installguard.go`(安装模式拦截,未安装非 `/setup` → 503) |
| `setting/config.go` | 收敛:仅读 `TTS_DB_PATH`、`PORT`、初始化凭证;启动汇总展示"模式/lock/音色数" |
| `main.go` | 启动流程:初始化 DB → 安装检测 → 模式分支 |
| `router/setup.html` | 新增引导页(Vue3 + axios,`//go:embed`) |
| `go.mod` | 新增 `modernc.org/sqlite` |
| `.env.example` / `README.md` / `docker-compose.yml` | 更新为新的引导参数与首次安装说明 |
### 8.3 环境变量收敛表
**迁移前(现状,会持续膨胀):**
```
BYTEDANCE_TTS_API_KEY
BYTEDANCE_TTS_RESOURCE_ID
BYTEDANCE_TTS_SPEAKER
BYTEDANCE_TTS_FORMAT
BYTEDANCE_TTS_SAMPLE_RATE
BYTEDANCE_TTS_BIT_RATE
BYTEDANCE_TTS_MODEL
BYTEDANCE_TTS_MODEL_TYPE
BYTEDANCE_TTS_EXPLICIT_LANGUAGE
BYTEDANCE_TTS_ENABLE_SUBTITLE
BYTEDANCE_TTS_TIMEOUT
BYTEDANCE_TTS_DEBUG
OPENAI_TTS_API_KEY
ALLOWED_ORIGINS
TRUSTED_PROXY_HOPS
PORT
```
**迁移后(仅引导参数,其余进库):**
```
TTS_DB_PATH # 数据库/lock 目录(默认 ./)
PORT # 监听端口
TTS_ADMIN_KEY # 安装初始化凭证(可选,不设则启动打印一次性 token)
OPENAI_TTS_API_KEY # 管理 API / 合成 API 鉴权(可选,迁移进 settings 或保留)
ALLOWED_ORIGINS # CORS(可保留,属运行环境而非业务配置)
TRUSTED_PROXY_HOPS # 反代拓扑参数(保留,属部署环境)
```
> `BYTEDANCE_TTS_DEBUG`、`TRUSTED_PROXY_HOPS`、`ALLOWED_ORIGINS` 属"部署/运维环境"而非"业务配置",可留在 env;其余 TTS 业务配置全部进库。
---
## 9. 安全设计
| 项 | 措施 |
|---|---|
| Setup 劫持 | 初始化凭证(`TTS_ADMIN_KEY` 或一次性 token)+ 可选回环限制;完成后 `/api/setup` 永久关闭 |
| 合成/管理鉴权 | 复用 `OPENAI_TTS_API_KEY`(Bearer);voice 路由不绕过鉴权 |
| 敏感信息 | API Key 在引导页只进不出;日志/`/health` 不回显明文 Key(沿用 `maskAPIKey`) |
| 输入校验 | voice 名白名单(字母数字 `_-`)、长度限制;SQL 全部参数化,防注入 |
| lock/DB 写入 | 先写库后写 lock;lock 原子创建;损坏先备份再回退 |
| 默认音色保护 | 删除被 `default_speaker` 引用的音色时拒绝(409) |
---
## 10. 开发计划与里程碑
| 里程碑 | 内容 | 验收标准 |
|---|---|---|
| M1 存储层 | `store/` 包:SQLite 接入、两表建表、迁移、settings/voices CRUD、`integrity_check` | `go build` 通过;单测覆盖 CRUD |
| M2 安装流程 | `installer/`:lock 检测/创建/删除、安装模式判定、损坏回退;`middleware/installguard` | 无 lock → 安装模式;有 lock → 正常模式;损坏库 → 备份+回退 |
| M3 引导 UI | `/setup` 引导页 + `POST /api/setup`(token 校验、写库、写 lock) | 首次访问可完成安装;重复安装被拒;token 错误 401 |
| M4 音色路由 | `/api/voices` CRUD + `/v1/audio/speech` voice 查库路由 | 新增音色后 voice 生效;未知 voice 400;默认音色兜底 |
| M5 收敛与文档 | `setting/` 收敛、`.env.example`/README/部署更新、启动摘要展示模式与音色数 | 迁移后仅引导 env;文档与行为一致 |
| M6 测试收尾 | 覆盖 lock 判定、setup 流程、voice 路由、损坏回退的集成测试 | 关键路径有自动化测试 |
建议 M1→M2 连续做(安装流程是主链路),M3 与 M2 可并行;M4 依赖 M1 完成。
---
## 11. 风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| `modernc.org/sqlite` 体积增大 | 单二进制从 ~7MB 增至 ~40MB | 接受;如不可接受改 CGO 版(牺牲单文件) |
| Setup 劫持(公网部署) | 他人抢先配置 | 初始化凭证 + 回环限制 + 完成后关闭端点(见 6.4) |
| 库损坏导致服务不可用 | 服务起不来 | 自愈回退:备份 + 删 lock + 重新安装(见 6.3) |
| 老用户迁移 | 现有 env 用户升级后无库 | 引导页预填旧 env 值;README 给出迁移步骤 |
| voice 名冲突/默认引用 | 删除默认音色致不可用 | 唯一约束 + 默认音色删除保护(409) |
| 并发写库 | 数据竞争 | 单写锁(`database/sql` 默认 + 业务层互斥),单用户场景风险低 |
| 表结构未来升级 | 旧库不兼容 | lock 内容带版本号;`migrate.go` 预留版本迁移 |
---
## 12. 验收标准(本期 MVP)
1. 删除所有业务 env 后,首次启动进入 `/setup` 引导页,可完成安装(全局 + ≥1 音色);
2. 安装完成后有 `installed.lock`,重启进入正常模式,`/v1/audio/speech` 可用;
3. 通过 `/api/voices` 新增音色后,请求带该 `voice` 能正常合成;未知 voice 返回 400;
4. 未传 `voice` 时使用默认音色;
5. 手动制造损坏库 → 自动备份 `.corrupt-*` 并删 lock 回退安装模式;
6. 安装模式下 `/v1/audio/speech` 返回 503 或跳转 `/setup`;
7. 单二进制运行,无外部文件依赖(引导页已 embed)。
---
## 13. 非目标(明确不做)
- 本期不做多服务商聚合、流式输出、缓存、字幕透出、ASR;
- 不做完整的运营管理后台(仅引导页,管理 API 先行);
- 不做多租户/多用户体系(个人自用,单管理员)。
---
*文档版本:v0.1 · 状态:待评审 · 配套代码基线:Volcano-Engine-TTS-UI @ develop*
+1 -1
View File
@@ -158,7 +158,7 @@ TRUSTED_PROXY_HOPS 未设置,使用默认启发式模式(XFF 链尾第一个公
已配置 TRUSTED_PROXY_HOPS=2(精确模式,信任 2 跳反代) 已配置 TRUSTED_PROXY_HOPS=2(精确模式,信任 2 跳反代)
``` ```
也可在 `GetClientIP` 临时加 `log.Printf` 打印解析结果,或写一个 Go 测试用例(参见 DEBT-1 单元测试任务)来覆盖不同 XFF 链场景。生产环境不要保留 debug 日志。 也可在 `GetClientIP` 临时加 `log.Printf` 打印解析结果,或参考已有的 `middleware/ratelimit_test.go`(24 个 XFF 表驱动用例)来扩展更多 XFF 链场景。生产环境不要保留 debug 日志。
### Resource ID 说明 ### Resource ID 说明
-197
View File
@@ -1,197 +0,0 @@
# 漏洞报告 — Volcano-Engine-TTS-UI
## 元信息
| 项目 | 内容 |
|---|---|
| 目标 | ByteDance TTS v3 → OpenAI 兼容接口适配器(Go) |
| 审查范围 | 全部 12 个包、约 2400 行源码(不含 health.html 前端静态页) |
| 审查方式 | 人工代码审查 + `go build` / `go vet`(均通过) |
| 分支/提交 | develop @ 78c7200 |
| 报告日期 | 2026 年 8 月 25 日 |
| 严重度分级 | 🔴 高(必须修复)/ 🟠 中(建议修复)/ 🟡 低(视部署环境)/ ⚪ 信息(记录备查) |
---
## 漏洞清单(按严重度)
| 编号 | 严重度 | 标题 | 位置 | 一句话影响 |
|---|---|---|---|---|
| VUL-004 | 🔴 高 | `.env` 未被 `.gitignore` 忽略,凭据可能入库/入镜像 | `.gitignore` | API Key 随 git 提交或 Docker 镜像层泄露 |
| VUL-001 | 🟠 中 | aac/flac 响应 Content-Type 与数据不一致 | controller/tts.go、adapter/volcano/synthesis.go | 客户端按 AAC 解码 MP3 数据,播放失败 |
| VUL-003 | 🟠 中 | `X-Forwarded-For` 信任链可伪造 IP 绕过限流 | middleware/ratelimit.go | 反代追加模式下限流失效 |
| VUL-002 | 🟡 低 | transport 层错误不进入 `UpstreamErrors` 指标 | metrics/metrics.go、adapter/volcano/synthesis.go | 网络故障在监控上不可见 |
| VUL-005 | 🟡 低 | 日志注入:客户端可控内容原样写入日志 | middleware/logger.go、controller/tts.go | 可伪造日志行 |
| VUL-006 | 🟡 低 | `/metrics`、`/health`、`/dashboard` 无鉴权 | router/router.go | 公网暴露时泄漏运行细节(设计权衡) |
| VUL-007 | 🟡 低 | `OPENAI_TTS_API_KEY` 未设置时鉴权完全关闭 | middleware/auth.go | 公网直连即无访问控制(设计权衡) |
| VUL-008 | ⚪ 信息 | speed 超范围静默截断 | adapter/volcano/request.go | 0.25~0.5x、2.0~4.0x 实际被 clamp,无提示 |
| VUL-009 | ⚪ 信息 | WAV 输出采样率依赖配置而非上游实际值 | adapter/volcano/audio.go | 配置错误导致音频变速 |
---
## VUL-004 🔴 高 — `.env` 未被忽略,凭据可能入库/入镜像
**位置**: `.gitignore`(全文件仅忽略构建产物与编辑器文件)
**描述**: README 与 `.env.example` 均指导用户执行 `cp .env.example .env` 后填入火山 API Key。但 `.gitignore` **没有包含 `.env`**。任何按此流程操作并执行 `git add .` / `git commit` 的用户,都会把含 `BYTEDANCE_TTS_API_KEY`、`OPENAI_TTS_API_KEY` 的文件提交进仓库历史(即使之后删除,历史中仍可找回)。Dockerfile 第 8 行 `COPY . .` 同样会把 `.env` 拷入镜像层。
**影响**: 火山账号 API Key 泄露 → 冒用额度、产生费用、音色资源被盗用。密钥一旦进入 git 历史或镜像层即视为已泄露,只能吊销重建。
**修复建议**:
```gitignore
# Secrets
.env
.env.*
!.env.example
```
**验证**: 当前工作区无 `.env` 文件,仓库历史也未发现已提交的 `.env`(已核查 `git log` 提交列表无该文件),属"配置隐患"而非"已泄露"。
---
## VUL-001 🟠 中 — aac/flac 响应 Content-Type 与真实数据不一致
**位置**: controller/tts.go:196-212(`contentTypeFor`)、adapter/volcano/synthesis.go:59-63、118-119
**描述**: 客户端请求 `response_format: "aac"`(或 `flac`)时,调用链为:
```
resolveClientFormat("aac") → "aac"(白名单放行)
synthesis: opts.Format = "mp3"(上游降级,正确)
synthesis: finalFormat = clientFormat = "aac"(错误,保留客户端格式)
controller: Content-Type = contentTypeFor("aac") = "audio/aac"(错误)
```
实际响应字节是 **MP3**,但 `Content-Type` 是 `audio/aac`。
**影响**: 客户端(浏览器 `<audio>`、播放器 SDK)按 AAC 解码器处理 MP3 流,轻则播放失败/杂音,重则解码崩溃。README 声称"降级到 mp3",但响应头未同步降级。
**修复建议**(二选一):
1. `synthesis.go` 在降级后把 `finalFormat` 置为实际上游格式(`mp3`);
2. `contentTypeFor` 对 `aac`/`flac` 直接返回 `audio/mpeg`。
推荐方案 1(响应头应反映真实数据)。
---
## VUL-003 🟠 中 — X-Forwarded-For 信任链可伪造 IP 绕过限流
**位置**: middleware/ratelimit.go:129-150(`GetClientIP`)
**描述**: `GetClientIP` 在直连 IP 为私有地址(即判定为反代)时,信任 `X-Forwarded-For` 的**第一个**值,其次信任 `X-Real-IP`。若反代(nginx 等)使用追加模式(`$proxy_add_x_forwarded_for`),攻击者发送 `X-Forwarded-For: 1.2.3.4`,反代追加真实 IP 后请求头为 `1.2.3.4, 真实IP`,代码取 `1.2.3.4`。
**影响**:
- 攻击者每次请求携带不同伪造 IP,即可绕过 100 次/分钟的 IP 限流(限流 key 由该函数返回值决定);
- 可伪装成受害 IP 请求,把受害 IP 的限流配额耗尽(间接 DoS)。
**前提**: 服务必须部署在反代之后(反代 IP 为私有)。直接公网直连时直连 IP 非私有,不走信任分支,不受影响。
**修复建议**(任一):
1. 反代配置覆盖而非追加:`proxy_set_header X-Forwarded-For $remote_addr`;
2. 代码改取 `XFF` **最后一个**值(追加模式下最后一个为真实来源);
3. 部署时用 `X-Real-IP` 且确保反代覆盖该头,代码优先信任 `X-Real-IP`。
---
## VUL-002 🟡 低 — transport 层错误不进入 UpstreamErrors 指标
**位置**: metrics/metrics.go:134-136、adapter/volcano/synthesis.go:90-92、112
**描述**: `UpstreamFinished` 中 `if errCode != 0 { UpstreamErrors.Inc(...) }`。传输错误(连接失败、DNS 失败、读流失败)时调用方传入的 `errCode` 均为 0:
- `client.PostStream` 失败 → `UpstreamFinished(..., 0)` → 不计
- `ParseStream` 读流错误 → `UpstreamError{Code: 0}` → 不计
而 `codeLabel`(metrics/metrics.go:148-158)明确设计了 `code == 0 → "transport"` 分类,**该分类永远不会被触发**。
**影响**: 上游网络故障时 `tts_upstream_errors_total` 不增长,`/metrics` 与监控面板无法发现"火山接口连不上"类故障,只能从日志人工发现。
**修复建议**: 将 transport 错误单独计数,例如 `if errCode != 0 || status == "transport_error" { UpstreamErrors.Inc(Labels{"code": codeLabel(errCode)}) }`。
---
## VUL-005 🟡 低 — 日志注入
**位置**: middleware/logger.go:26、adapter/volcano/synthesis.go:100(`Message` 拼入上游响应体)
**描述**: 访问日志直接拼接 `r.RequestURI`(客户端可控,URL 中可含 `\n`/`\r`);上游非 200 响应体 `rawBody` 拼入错误日志。Go `log` 不做转义,原样输出。
**影响**: 攻击者可在请求 URL 中注入换行符,伪造服务端日志行(如伪造"合成成功"记录、注入误导信息),干扰排障;无代码执行风险。
**修复建议**(低优先): 对 RequestURI 做换行转义(`strings.NewReplacer("\n", "\\n", "\r", "\\r")`)。
---
## VUL-006 🟡 低 — 监控端点无鉴权(设计权衡)
**位置**: router/router.go:21-31
**描述**: `/health`、`/metrics`、`/dashboard` 均不鉴权(README 明示,与 Prometheus 抓取场景对齐)。
**影响**: 若公网直接暴露,任何人可查看 `/metrics`(含 speaker/model/format 业务标签、请求计数、上游错误聚合)与 `/dashboard`(运行状态、配置检查结果)。不涉及凭据,但为侦察提供信息。
**判定**: 属于明确的设计决策,个人/内网使用可接受;公网部署建议通过反代鉴权(如 basic auth)保护 `/metrics`。
---
## VUL-007 🟡 低 — 未配置 OPENAI_TTS_API_KEY 时鉴权完全关闭
**位置**: middleware/auth.go:20-22、setting/config.go:64-79
**描述**: `ValidateAPIKey` 在 `setting.Auth.APIKeys` 为空时直接返回 `true`(全部放行)。该变量仅在 `OPENAI_TTS_API_KEY` 设置后才会填充。
**影响**: 公网直接暴露且未配置该环境变量时,任何人均可无限制调用 TTS 合成,消耗火山额度。
**判定**: 属设计行为(内网可信),README 已有说明。公网部署必须配置该变量,或由反代承担鉴权。
---
## VUL-008 ⚪ 信息 — speed 超范围静默截断
**位置**: adapter/volcano/request.go:89-101、controller/tts.go:132-141
**描述**: README 声明 `speed` 支持 0.25~4.0;controller 按此范围 clamp,但火山 `speech_rate` 仅支持 [-50, 100](即 0.5x~2.0x)。`0.25~0.5x` 与 `2.0~4.0x` 区间会被二次 clamp 截断,且无任何客户端提示。
**影响**: 用户请求 0.25x 实际得到 0.5x 语速,表现与预期不符。
**修复建议**: README 修正文档范围,或对超范围请求返回 400 而非静默截断。
---
## VUL-009 ⚪ 信息 — WAV 采样率依赖配置而非上游实际值
**位置**: adapter/volcano/audio.go:32-39、controller/tts.go:121
**描述**: `WrapWAVHeader` 使用 `opts.SampleRate`(环境变量 `BYTEDANCE_TTS_SAMPLE_RATE`,默认 24000)写 WAV 头。若上游实际返回的 PCM 采样率与配置不一致(配置错误或上游忽略该参数),WAV 头与数据不匹配。
**影响**: 音频以错误速率播放(变速/变调)。
**判定**: 正常配置下无影响;配置异常时表现为"音频怪声",README 第 3 条已有排查指引。
---
## 安全加固建议(非漏洞)
1. **Docker 环境变量**: compose 中密钥通过 `environment` 明文传递,进程环境可见(`/proc/<pid>/environ`)。生产可改用 Docker Secrets 或启动时注入。
2. **TLS**: 当前 HTTP 明文,建议生产经反代(nginx/caddy)终结 TLS,或服务前挂证书。
3. **依赖固定**: go.mod 仅锁定 `gorilla/mux v1.8.1`(2018 年发布),建议 `go get -u` 检查是否存在已知 CVE 的新版本,或至少 `go mod verify`。
## 工程债务(非安全,记录备查)
| 项目 | 说明 |
|---|---|
| 零单元测试 | 全部 12 个包无 `_test.go`;流解析(曾有 4 次 bug 修复)、WAV 拼头、speech_rate 转换、限流窗口、Prometheus 转义均无自动化回归保护 |
| 死代码 | `ratelimit_middleware.go` 整文件未引用;`auth.go:InitAPIKeys`、`cors.go:InitCORSConfig` 为未被调用的 no-op;`dto.ByteDanceTTSConfig` + `setting/config.go:311` 占位引用 |
| 冗余代码 | `resolveClientFormat`(controller/tts.go:49-52)两分支同值;`common.MaxResponseTimes`/`MaxErrors` 常量未使用 |
| 云盘占用 | 注释表明 `ratelimit_middleware.go` 因"云盘同步被永久占用"无法删除,仓库位于云盘目录,git 操作与文件删除存在异常风险 |
## 已核查无风险项
- ✅ API Key 比较使用 `subtle.ConstantTimeCompare`,无时序侧信道
- ✅ 启动日志与 `/health` 对 API Key 脱敏(`maskAPIKey`)
- ✅ 请求体上限 1MB、文本上限 5000 字、model 名长度/字符校验
- ✅ 并发信号量 + IP 限流仅对 `/v1/` 生效,监控路径豁免;CORS 预检不消耗配额
- ✅ telemetry label key 注册时锁定,当前 cardinality 可控(无客户端可控高基数标签)
- ✅ 上游连接池复用、超时(30s)与 context 取消正确传播
- ✅ 优雅退出(SIGINT/SIGTERM → 5s 内 Shutdown)
- ✅ 无 `_test.go` 之外的明显并发竞态:全局配置启动期写入后只读,共享状态均有锁
-229
View File
@@ -1,229 +0,0 @@
package middleware
import (
"net/http/httptest"
"testing"
)
// TestGetClientIP 覆盖 XFF 解析在两种模式下的关键场景。
// 表驱动测试,每个 case 独立设置 trustedProxyHops,验证 GetClientIP 输出。
func TestGetClientIP(t *testing.T) {
tests := []struct {
name string
mode int // 0=启发式, N>0=精确 N 跳
remoteAddr string // 直连 IP:port
xff string // X-Forwarded-For 头(空则不设)
xri string // X-Real-IP 头(空则不设)
want string
}{
// === 直出部署(directIP 是公网,XFF 分支不进)===
{
name: "直出_无XFF",
mode: 0,
remoteAddr: "1.2.3.4:5678",
want: "1.2.3.4",
},
{
name: "直出_XFF被忽略",
mode: 0,
remoteAddr: "1.2.3.4:5678",
xff: "fake",
want: "1.2.3.4", // 公网直连不走 XFF 分支
},
{
name: "直出_精确模式也不走XFF",
mode: 2,
remoteAddr: "1.2.3.4:5678",
xff: "fake, 5.6.7.8",
want: "1.2.3.4",
},
// === 单跳反代 ===
{
name: "单跳_启发式",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "单跳_精确N1",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4",
},
// === 攻击者伪造首值 ===
{
name: "伪造_启发式跳过fake",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "fake, 1.2.3.4",
want: "1.2.3.4",
},
{
name: "伪造_精确N1也跳过fake",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "fake, 1.2.3.4",
want: "1.2.3.4", // target=1, 跳过 fake 取 real
},
{
name: "伪造_多个假值前缀",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "fake1, fake2, 1.2.3.4",
want: "1.2.3.4", // 从尾扫,只看最后一个
},
// === 多跳 CDN+nginx ===
{
name: "多跳_启发式返回CDN边缘",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "203.0.113.5", // 链尾公网=CDN 边缘
},
{
name: "多跳_精确N2返回真实client",
mode: 2,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "1.2.3.4", // 倒数第2=真实 client
},
{
name: "多跳_精确N1不够穿透",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "203.0.113.5", // 数到 nginx,没穿透到 client
},
// === 链尾私有 IP ===
{
name: "链尾私有_启发式跳过",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 10.0.0.1",
want: "1.2.3.4", // 跳过私有取公网
},
{
name: "链尾私有_精确N1取末值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 10.0.0.1",
want: "10.0.0.1", // 精确模式不跳私有
},
// === X-Real-IP 兜底 ===
{
name: "无XFF_走XRI",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xri: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "XFF全非法_走XRI",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_ip, also_not",
xri: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "XRI被XFF优先_但XFF全非法",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_an_ip",
xri: "1.2.3.4",
want: "1.2.3.4",
},
// === 全部私有 IP(启发式无解)===
{
name: "全私有_启发式回退directIP",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "192.168.1.1, 172.16.0.1",
want: "10.0.0.1", // 全跳私有,走 directIP
},
// === 畸形/空 XFF ===
{
name: "畸形XFF_启发式跳过畸形",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_an_ip, 1.2.3.4",
want: "1.2.3.4",
},
{
name: "全空XFF_回退directIP",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: " , , ",
want: "10.0.0.1",
},
{
name: "XFF带前后空格",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: " 1.2.3.4 , 5.6.7.8 ",
want: "5.6.7.8", // TrimSpace 处理
},
// === 精确模式 N 超出链长 ===
{
name: "精确N超出链长_回退到首值",
mode: 5,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target<0 保护,取首个合法
},
{
name: "精确N等于链长_取首值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target=0
},
{
name: "精确N大于链长_取首值",
mode: 2,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target<0,fall back
},
// === 精确模式链中含畸形 ===
{
name: "精确N1_链中畸形回退到首值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, not_ip",
want: "1.2.3.4", // target=1(not_ip 失败)→ i=0(1.2.3.4 成功)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trustedProxyHops = tt.mode
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = tt.remoteAddr
if tt.xff != "" {
r.Header.Set("X-Forwarded-For", tt.xff)
}
if tt.xri != "" {
r.Header.Set("X-Real-IP", tt.xri)
}
got := GetClientIP(r)
if got != tt.want {
t.Errorf("GetClientIP() = %q, want %q", got, tt.want)
}
})
}
// 重置为默认,避免影响其他测试或运行时行为
trustedProxyHops = 0
}