diff --git a/.dockerignore b/.dockerignore index 45b0f8d..9a5c2ee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,8 @@ *.exe *.md .env -.env.example +.env.* +!.env.example .git .gitignore tts_api_architecture.html diff --git a/.env.example b/.env.example index a65dfb4..b44a3c9 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,17 @@ BYTEDANCE_TTS_SAMPLE_RATE=24000 # OpenAI兼容接口的API密钥(可选,多个用逗号分隔) OPENAI_TTS_API_KEY=your_openai_compatible_key_here +# 反代拓扑配置(0-10)。控制 X-Forwarded-For 解析方式,影响 IP 限流的 key。 +# 不设置 / 0:启发式模式(默认)—— 从 XFF 链尾扫描,跳过私有 IP,返回第一个公网 IP +# 适合 90% 部署(单跳/多跳/直出),无需了解精确跳数 +# N (N>0) :精确模式 —— 精准到真实 client IP,需要正确配置跳数 +# N=1:单跳反代(client → nginx → 本服务) +# N=2:双跳反代(client → CDN → nginx → 本服务,如 Cloudflare + nginx) +# N=3:三跳,以此类推 +# 直出部署(无反代):无需配置,XFF 分支不会执行 +# 详见 README "反代拓扑与 X-Forwarded-For 解析"章节 +# TRUSTED_PROXY_HOPS= + # CORS 跨域白名单(逗号分隔;开发环境可设 *;空则拒绝所有跨域) # ALLOWED_ORIGINS=https://example.com,https://app.example.com diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..bc3a061 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,65 @@ +name: Docker Publish + +on: + push: + tags: + - 'v*' + workflow_dispatch: # 允许手动触发测试 + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # 拉完整历史,git describe 能取到 tag + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from git + id: version + run: | + echo "version=$(git describe --tags --always --dirty)" >> "$GITHUB_OUTPUT" + echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=sha,format=short + labels: | + org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.revision=${{ steps.version.outputs.commit }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.version.outputs.version }} + COMMIT=${{ steps.version.outputs.commit }} diff --git a/.gitignore b/.gitignore index e7ef1d8..0ecf443 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Go build cache +# Go build cache .gocache/ *.exe *.test @@ -11,4 +11,12 @@ Thumbs.db # Logs -*.log \ No newline at end of file +*.log + +# Secrets (do NOT commit local .env files; keep .env.example tracked as template) +.env +.env.* +!.env.example + +# Local-only working notes (use task board for shared tracking) +TODO.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 101b7c5..95f50e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,15 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o tts-api . +# VERSION 由 CI/CD 传入,通常为 `git describe --tags --always --dirty` 的输出 +# COMMIT 为 `git rev-parse --short HEAD` +# 本地默认 dev +ARG VERSION=dev +ARG COMMIT=dev +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags "-X github.com/volcano-tts/tts-api/version.Version=${VERSION} \ + -X github.com/volcano-tts/tts-api/version.Commit=${COMMIT}" \ + -o tts-api . FROM alpine:3.21 diff --git a/README.md b/README.md index 5ac04b1..c46c6e4 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ tts-api.exe |--------|------|--------| | `BYTEDANCE_TTS_TIMEOUT` | 单次合成超时 | `30s` | | `BYTEDANCE_TTS_FORMAT` | 上游实际请求的音频格式(mp3 / pcm / ogg_opus);客户端要求 wav 时内部自动转 pcm + 本地拼 WAV 头 | `mp3` | -| `BYTEDANCE_TTS_SAMPLE_RATE` | 上游采样率(8000 / 16000 / 22050 / 24000 / 32000 / 44100 / 48000) | `24000` | +| `BYTEDANCE_TTS_SAMPLE_RATE` | 上游采样率(8000 / 16000 / 22050 / 24000 / 32000 / 44100 / 48000);**此值直接写入 WAV 头,需与上游实际 PCM 采样率一致,否则音频变速变调** | `24000` | | `BYTEDANCE_TTS_BIT_RATE` | MP3 比特率,仅 mp3 生效 | 无 | ### 复刻 2.0 扩展参数 @@ -79,10 +79,87 @@ tts-api.exe | 变量名 | 说明 | 默认值 | |--------|------|--------| -| `OPENAI_TTS_API_KEY` | OpenAI 兼容接口的 API Key(逗号分隔支持多个) | 无(不鉴权) | +| `OPENAI_TTS_API_KEY` | 🔴 **公网必设** OpenAI 兼容接口的 API Key(逗号分隔支持多个);**未设置时鉴权完全关闭** | 无(不鉴权) | +| `TRUSTED_PROXY_HOPS` | X-Forwarded-For 解析模式(0=启发式/默认,>0=精确 N 跳) | `0`(启发式) | | `PORT` | 服务监听端口 | `8080` | | `ALLOWED_ORIGINS` | CORS 跨域白名单(逗号分隔,调试可设 `*`;空则拒绝所有跨域) | 无 | +### 反代拓扑与 X-Forwarded-For 解析 + +当服务部署在反代(nginx / caddy / CDN)后面时,反代会通过 `X-Forwarded-For`(XFF)头传递真实客户端 IP。本服务通过 `TRUSTED_PROXY_HOPS` 环境变量控制 XFF 解析方式,支持两种模式。 + +#### 何时需要关心这个配置 + +| 部署方式 | 是否需要配置 | +|---|---| +| 服务直接暴露公网 IP(无反代)| ❌ 不适用,跳过本节 | +| 服务前有 1 个反代(nginx / caddy)| ❌ 不必配置,启发式模式自动处理 | +| 服务前有 2 跳以上反代(CDN + 自建反代)| ⚠️ 启发式模式"够用",需要精准按真实 client 限流时再设 | + +> **直出部署(无反代)的用户**:本节不适用,跳过阅读。`TRUSTED_PROXY_HOPS` 在你的部署下不会被读取。 + +#### 启发式模式(默认 / `TRUSTED_PROXY_HOPS=0`) + +从 XFF 链尾向前扫描,**跳过私有 IP,返回第一个公网 IP**。 + +适用场景:单跳反代(最常见)、多跳含公网代理(CDN + nginx)。 + +**行为示例**: + +| XFF 链 | 启发式返回 | 备注 | +|---|---|---| +| `1.2.3.4` | `1.2.3.4` | 单跳,真实 client | +| `fake, 1.2.3.4` | `1.2.3.4` | 攻击者伪造首值,跳过 fake | +| `1.2.3.4, 5.6.7.8, 10.0.0.1` | `5.6.7.8` | 多跳,返回最末公网 IP(CDN 边缘) | +| `1.2.3.4, 192.168.1.1` | `1.2.3.4` | 链尾是私有 IP,跳过 | + +**优点**:零配置,大多数部署自动正确。 + +**限制**:多跳 CDN 场景下,限流粒度为"按 CDN 边缘 IP"而非"按真实 client"。攻击者填满某 CDN 边缘配额可能影响该 CDN 下的其他用户——但无法伪造身份、无法越权。 + +#### 精确模式(`TRUSTED_PROXY_HOPS=N`,N > 0) + +从 XFF 链尾倒数第 N+1 个位置取值,即"信任最近 N 跳反代,取该信任链之前那一跳的 IP"。 + +适用场景:多跳 CDN + 反代,且需要精准按真实 client 限流。 + +**N 的确定方法**:统计客户端到本服务之间的反代跳数。 + +| 拓扑 | 跳数 | 配置 | +|---|---|---| +| `client → nginx → 本服务` | 1 | `TRUSTED_PROXY_HOPS=1` | +| `client → Cloudflare → nginx → 本服务` | 2 | `TRUSTED_PROXY_HOPS=2` | +| `client → CDN → WAF → nginx → 本服务` | 3 | `TRUSTED_PROXY_HOPS=3` | + +**行为对比**(以 `client(1.2.3.4) → CDN(203.0.113.5) → nginx(10.0.0.1) → 本服务` 为例,XFF 链 = `1.2.3.4, 203.0.113.5`): + +| `TRUSTED_PROXY_HOPS` | 返回 | 评价 | +|---|---|---| +| 0(默认启发式)| `203.0.113.5` | CDN 边缘 IP,限流粒度粗 | +| 1(数到 nginx,未穿透)| `203.0.113.5` | 配置不当,与默认相同 | +| 2(穿透到真实 client)| `1.2.3.4` | 精准到真实 client ✓ | +| 3(超出实际跳数)| `directIP`(链长不足保护)| 配置错误,需修正 | + +#### 为什么两种模式都从链尾扫描 + +XFF 链的第一个值是**客户端可控**的:攻击者可以发送任意 `X-Forwarded-For: 1.2.3.4`,若反代用追加模式(如 nginx 默认的 `$proxy_add_x_forwarded_for`),链尾才会追加真实 IP。 + +若代码取首值,攻击者每次换伪造 IP 即可绕过 IP 限流,也可伪装成受害 IP 把其配额耗尽(间接 DoS)。两种模式都从链尾扫描,天然免疫这种攻击。 + +#### 验证当前模式 + +启动期日志会显示当前模式: + +``` +TRUSTED_PROXY_HOPS 未设置,使用默认启发式模式(XFF 链尾第一个公网 IP) +# 或 +已配置 TRUSTED_PROXY_HOPS=0(启发式模式,等同默认) +# 或 +已配置 TRUSTED_PROXY_HOPS=2(精确模式,信任 2 跳反代) +``` + +也可在 `GetClientIP` 临时加 `log.Printf` 打印解析结果,或写一个 Go 测试用例(参见 DEBT-1 单元测试任务)来覆盖不同 XFF 链场景。生产环境不要保留 debug 日志。 + ### Resource ID 说明 | Resource ID | 模型说明 | @@ -166,6 +243,8 @@ CORS拦截: 来源="https://..." 路径=/v1/audio/speech 方法=POST 客户端=. ## API 使用说明 +> ⚠️ **公网部署前必读**:如果你的服务暴露在公网,**必须**设置 `OPENAI_TTS_API_KEY` 或由前置反代(nginx / caddy)承担鉴权。未设置时 `Authorization` 头完全跳过校验,任何能访问 `:8080` 的人都能调用 TTS 合成,消耗你的火山额度。详见[部署 → 公网安全清单](#公网部署安全清单)。 + ### OpenAI 兼容接口 **端点:** `POST /v1/audio/speech` @@ -191,7 +270,7 @@ CORS拦截: 来源="https://..." 路径=/v1/audio/speech 方法=POST 客户端=. - `input` — 要合成的文本 - `voice` — 发音人(OpenAI 兼容,实际用 `BYTEDANCE_TTS_SPEAKER`) - `response_format` — 输出格式:`mp3`(默认)/ `opus`(映射 ogg_opus)/ `wav` / `pcm` / `aac` / `flac`(降级到 mp3) -- `speed` — 语速,0.25 ~ 4.0(火山侧转换为 speech_rate [-50, 100]) +- `speed` — 语速倍率,客户端接受范围 0.25 ~ 4.0;**火山实际生效范围 0.5 ~ 2.0**(speech_rate [-50, 100]),超出范围会被静默截断,客户端无感反馈 **格式映射:** @@ -281,6 +360,40 @@ scrape_configs: `/dashboard` 展示服务状态 + 内存 + 配置信息,并内嵌 `/metrics` 预览;Grafana 等工具可直接基于上面指标做面板。 +### ⚠️ 公网部署:监控端点无鉴权 + +`/metrics`、`/health`、`/dashboard` **均不鉴权**,这是对齐 Prometheus 抓取场景的设计权衡: + +| 端点 | 暴露内容 | 风险 | +|---|---|---| +| `/metrics` | 业务标签(speaker/model/format)、运行指标、错误计数 | 侦察面:可推断使用量、技术栈、错误模式 | +| `/health` | 服务状态、版本号、运行时长、内存 | 侦察面:版本号可用于匹配已知 CVE | +| `/dashboard` | 配置检查结果(含 `TTSConfigErr` 状态) | 信息泄露:可确认配置是否就绪 | + +**部署建议**: + +- **内网 / 反代后**:无影响,符合预期 +- **公网直接暴露**:在前置反代(nginx / caddy)上保护这些端点,示例 nginx 配置: + + ```nginx + location /metrics { + auth_basic "metrics"; + auth_basic_user_file /etc/nginx/.htpasswd; + allow 10.0.0.0/8; # 仅允许 Prometheus 服务器网段 + deny all; + } + location /dashboard { + auth_basic "admin"; + auth_basic_user_file /etc/nginx/.htpasswd; + } + location /health { + allow 10.0.0.0/8; # 或保留给监控系统访问 + deny all; + } + ``` + +- **最简方案**:反代层直接限制 `/metrics` 只能从 Prometheus 服务器 IP 访问,无需 basic auth + ## 架构 | 包 | 职责 | @@ -333,6 +446,34 @@ docker compose up -d 环境变量通过 `.env` 或 `docker-compose.yml` 传入。 +### 公网部署安全清单 + +公网直接暴露(`:8080` 可被互联网任意访问)时,**至少满足以下两条之一**,否则视为不安全的部署: + +1. **设置 `OPENAI_TTS_API_KEY`**(推荐,最简单) + ```bash + # .env + OPENAI_TTS_API_KEY=<32+ 位随机字符串> + ``` + 客户端请求时带 `Authorization: Bearer <那个字符串>`。 + +2. **前置反代承担鉴权**(nginx / caddy / Cloudflare Access) + - 反代层做 basic auth、mTLS、Cloudflare Access 等任一方案 + - 反代**仅**把鉴权后的请求转发到 `:8080`,Go 服务本身保持"无鉴权" + - 此时 `OPENAI_TTS_API_KEY` 可不设 + +**两个端点还需要单独保护**(无论上面哪种方案): + +- `/metrics`:暴露业务标签与运行指标,详见[观测 / Metrics → 公网部署](#公网部署监控端点无鉴权) +- `/dashboard`:暴露配置检查结果,同上 + +**未做保护的典型风险**: +- 任意人 curl `POST /v1/audio/speech` → 消耗你火山账号的字符额度 +- 任意人 `GET /metrics` → 推断你的使用量、技术栈、错误模式 +- 任意人 `GET /dashboard` → 确认你 TTS 配置就绪状态 + +**内网部署 / 私网反代后**:这些警示不适用,直接用就行。 + ## 常见问题 ### 1. `code=55000000, message=resource ID is mismatched with speaker related resource` diff --git a/VULNERABILITY_REPORT.md b/VULNERABILITY_REPORT.md new file mode 100644 index 0000000..ff232fb --- /dev/null +++ b/VULNERABILITY_REPORT.md @@ -0,0 +1,197 @@ +# 漏洞报告 — 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`。 + +**影响**: 客户端(浏览器 `