13 Commits
Author SHA1 Message Date
sun 0d3517eb5c fix: 安全加固(VUL-001~009)+ 构建版本注入 + 死代码清理' (#2) from develop into main
Docker Publish / build-and-push (push) Canceled after 0s
Reviewed-on: #2
2026-08-27 11:01:47 +08:00
sun cdc9a7c94b ci: 添加 GitHub Actions Docker 发布 workflow
为 Gitea→GitHub 镜像 + GitHub 镜像仓库场景准备 Docker 镜像
自动构建和发布流程。

触发条件:
  - push tag v*  (release 时打 tag 自动触发)
  - workflow_dispatch  (手动触发,可在 GitHub UI 测试)

构建:
  - 多平台:linux/amd64 + linux/arm64
  - 通过 QEMU + Buildx 跨架构构建
  - 推送到 ghcr.io/<github-user>/volcano-engine-tts-ui
  - 双标签:语义版本 + 短 commit hash
  - OCI labels 包含 version 和 revision

与之前 version 包配合:
  workflow 用 git describe --tags --always --dirty 算 VERSION
  (commit hash 类似),通过 --build-arg 传给 Dockerfile,
  Dockerfile 用 -ldflags 注入到 version.Version / version.Commit
  两个变量。最终 /health 端点返回构建时注入的真实版本信息。

前置条件(用户需自行完成):
  1. GitHub 建镜像仓库
  2. Gitea 配置 Push Mirror 到 GitHub(开启 Sync Tags)
  3. 仓库设为 Public(否则 GHCR 镜像默认 private)
2026-08-27 10:43:19 +08:00
sun c00c46e7a1 chore: 注入构建时版本信息到 /health 端点
现状:controller/tts.go:232 硬编码 Version: "2.0.0 (v3 API)",
不会随代码变化而更新,/health 无法反映实际跑的代码。
修复:四文件改动,实现构建时 ldflags 注入。
  1. 新建 version/version.go,声明两个包级变量:
       Version (默认 "dev")
       Commit  (默认 "dev")
  2. dto/health.go:HealthResponse 加 Commit 字段(JSON 输出多一字段)
  3. controller/tts.go:HealthResponse 用 version.Version / version.Commit
     替代硬编码字面量
  4. Dockerfile:加 ARG VERSION=dev ARG COMMIT=dev,
     go build 时通过 -ldflags 注入到 version 包的两个变量
效果:
  - 本地 go build (不传 ldflags) → version=dev commit=dev
  - 开发 build (git describe) → version=v0.2.0-5-g4abcd5 commit=g4abcd5
  - release build (打 tag 后) → version=v0.2.1 commit=<对应 hash>
测试环境(develop 分支,无 tag)显示距离上次 release 几个 commit +
具体 hash;生产环境(main + tag)显示干净 semver。
2026-08-27 10:18:34 +08:00
sun 3b3aa3b708 chore: 清理 DEBT-2 死代码(7 文件,约 30 行)
VUL-003 修复期间意外发现项目遗留一批死代码,本次一并清掉:

  - middleware/ratelimit_middleware.go(37 行,物理删除)
    文件内 RateLimit / ConcurrencyLimit 函数从 977e9cc 创建后
    从未被引用,370a217 commit 用 ratelimit_instrumented.go
    (带 metrics 埋点 + 路径过滤)取代了它。占用包体,清。

  - middleware/auth.go:InitAPIKeys(6 行)
    注释说"已在 setting.InitAuthConfig 中完成",无 op。

  - middleware/cors.go:InitCORSConfig(6 行)
    同上,setting.InitCORSConfig 已做实际工作。

  - dto/tts.go:ByteDanceTTSConfig 类型(7 行)
    完整的配置走 setting.TTSOptions + adapter/volcano.Options,
    此类型从未被任何代码实例化。

  - setting/config.go: var _ = dto.ByteDanceTTSConfig{} 占位(3 行)
    配合上方类型删除,移除 dto import。

  - controller/tts.go:resolveClientFormat
    合并 if reqFmt == "" 与 default 分支(都返回
    setting.TTSOptions.Format),2 行简化。

  - common/constants.go: MaxResponseTimes / MaxErrors
    定义后从未被任何文件引用。

  - middleware/ratelimit_instrumented.go 顶部注释
    移除对"原 ratelimit_middleware.go"的悬空引用,
    改为描述本文件相对路由使用实现的两个增强点。

影响:
  - 包体减少约 30 行
  - 降低新人接手时的代码理解成本
  - 零功能变更,24 个现有测试用例全过
2026-08-27 00:57:49 +08:00
sun 695b3ecf25 docs: README 修正 speed 与 sample_rate 描述 (VUL-008 / VUL-009)
VUL-008 (信息): speed 超范围静默截断无提示
  修复:「API 使用说明」speed 描述从"0.25 ~ 4.0"改为
  "客户端接受范围 0.25 ~ 4.0;火山实际生效范围 0.5 ~ 2.0
  (speech_rate [-50, 100]),超出范围会被静默截断,客户端无感反馈"。
  选 TODO 方案 1(诚实修正文档),不改代码。

VUL-009 (信息): WAV 采样率依赖配置而非上游实际值
  修复:BYTEDANCE_TTS_SAMPLE_RATE 描述加风险提示
  "此值直接写入 WAV 头,需与上游实际 PCM 采样率一致,
  否则音频变速变调"。
  上游 v3 协议不返回 sample_rate 字段,代码侧无法自动取真实值,
  仅文档强化。
2026-08-26 16:16:56 +08:00
sun 7343d5aa5c VUL-006 (低): 监控端点 (/metrics /health /dashboard) 无鉴权
判定:不引入新鉴权机制(会破坏 Prometheus 抓取),文档引导。
  修复:「观测 / Metrics」section 末尾新增「公网部署:监控端点
  无鉴权」段,含三端点风险表 + nginx 反代 basic auth 配置示例。

VUL-007 (低): 未设置 OPENAI_TTS_API_KEY 时鉴权完全关闭
  判定:不改代码(无 API Key 即不鉴权是 README 明示的设计),
  文档强化。
  修复:三处加强:
    1. 环境变量表 OPENAI_TTS_API_KEY 行加 🔴 公网必设 标记
    2. 「API 使用说明」section 顶部 callout 警示
    3. 「部署」section 末尾新增「公网部署安全清单」段,统一
       覆盖鉴权关闭与监控端点保护,形成完整安全姿态
2026-08-26 16:06:17 +08:00
sun 171503d775 fix: VUL-002 修复 transport 错误埋点缺失
VUL-002 (低): transport 层错误不进入 UpstreamErrors 指标
原代码用 if errCode != 0 判断是否记录错误,但合成链路中
多种错误场景的 errCode 本身是 0(火山 v3 业务码非 0 时才
会传入),导致这些场景在 /metrics 上完全不可见:
  - transport_error: client.PostStream 失败(DNS / 连接 / TLS)
  - request_error  : buildRequest 序列化失败
  - wrap_error     : WAV 头拼装失败
  - stream_error (code=0): 读流错误
修复:把判断改为 if status != "ok",status 是上游调用全链
路的权威错误指示器,任何非 ok 状态都计为错误。
codeLabel(0) → "transport" 已有定义,修复后该分类真正生效,
火山接口不可达等网络故障首次在监控上可见。
向后兼容:已统计的 http_XXX 错误(status="http_xxx" != "ok")
行为不变,新增 transport / request / wrap / stream 错误进入指标。
2026-08-26 15:35:24 +08:00
sun 91b0c8acee fix: VUL-005 修复日志注入(RequestURI 与上游错误体转义)
VUL-005 (低): 攻击者可在 HTTP 请求 URL 或上游错误响应中
注入 \n / \r 字符,伪造日志行干扰排障。无代码执行风险。

修复位置:
  - middleware/logger.go: 访问日志中的 r.RequestURI 是未经
    解析的原始请求行,客户端可控。转义 \n / \r 为字面字符
  - adapter/volcano/synthesis.go: 上游非 200 响应体 (rawBody)
    可能是攻击者控制的恶意内容,转义后再嵌入错误消息
2026-08-26 11:52:28 +08:00
sun a238e5c2a4 chore: 忽略本地 TODO.md,避免误提交
TODO.md 是本地待办清单,通过任务看板追踪更合适,不入 git 仓库。
2026-08-26 00:37:15 +08:00
sun 272565f736 fix: VUL-003 完整修复,启发式/精确双模式 XFF 解析
VUL-003 (中): X-Forwarded-For 信任链可被伪造 IP 绕过限流

本服务定位为公网入口,即使单人使用,公网暴露意味着攻击面
与公开服务等同,不能"够用就行"。

采用渐进式披露设计,平衡易用性与功能性:

  1. 启发式模式(默认, 不设环境变量 或 TRUSTED_PROXY_HOPS=0)
     - 从 XFF 链尾扫描,跳过私有 IP,返回第一个公网 IP
     - 适合 90% 部署(单跳/多跳/直出),无需了解精确跳数
     - 限制:多跳 CDN 场景下,限流粒度为"按 CDN 边缘 IP"
     - 直出部署:整个 XFF 分支不会执行

  2. 精确模式(TRUSTED_PROXY_HOPS=N, N>0)
     - 从 XFF 链尾倒数第 N+1 个位置取值
     - 精准到真实 client,需按实际反代跳数正确配置
     - N=1:单跳反代;N=2:CDN+反代;以此类推

  3. 两种模式都从链尾扫描
     - XFF 首值是客户端可控的,信任首值等于信任攻击者
     - 链尾由受控的反代添加,天然免疫伪造绕过

  4. 默认值从 1 改为 0(行为变化)
     - 旧默认:精确模式 N=1,取 XFF 末值
     - 新默认:启发式模式,跳过链尾私有 IP
     - 对单跳场景行为相同
     - 对多跳/链尾含私有 IP 场景新版更准确(返回真实公网 IP)

  5. 配套
     - middleware/ratelimit_test.go:24 个表驱动测试用例,
       覆盖直出/单跳/多跳/伪造/畸形/精确 N 边界,全部通过
     - setting/config.go:LogStartupSummary 显示当前 XFF 模式
     - .env.example:重写说明,标注默认行为 + 何时需配
     - README.md:新增"反代拓扑与 X-Forwarded-For 解析"章节
       (何时需要/两种模式/行为对比/为什么从链尾/启动日志验证)

  6. 已知边界:TRUSTED_PROXY_HOPS=00 等被 Atoi 解析为 0 的
     输入归入启发式模式,日志不会出现"精确模式 0 跳"矛盾输出。
2026-08-26 00:36:22 +08:00
sun ed3d7c6b61 本次代码审查(全 12 个包,约 2400 行)的交付物:
- 9 项漏洞(高 1 / 中 2 / 低 4 / 信息 2)
  - 已核查无风险项 8 条
  - 工程债务记录(零测试、死代码、云盘占用)
  - 安全加固建议(Docker 密钥传递、TLS、依赖固定)

供后续按优先级处理备查。
"
2026-08-25 23:10:34 +08:00
sun 72d0d6a3a9 VUL-001 (中): aac/flac 响应 Content-Type 与真实数据不一致
controller.tts.go:contentTypeFor 对 aac/flac 返回 audio/aac/flac,
  但 adapter/volcano/synthesis.go 在上游降级时仅修改 opts.Format,
  finalFormat 仍保留 clientFormat,导致响应头与字节流不符。
  修复:finalFormat 改为反映真实输出格式(非 wav 时取 opts.Format),
  客户端按 AAC/FLAC 解码 MP3 流的失败场景消除。

VUL-004 (高): .env 凭据泄露
  README 引导用户 cp .env.example .env 填密钥,但 .gitignore
  未忽略 .env,任何 git add . 都会把含 BYTEDANCE_TTS_API_KEY
  的文件提交进 git 历史,不可逆。
  修复:
  - .gitignore 新增 Secrets section,拦截 .env 与 .env.* 变体,
    保留 .env.example 作为模板追踪
  - .dockerignore 升级为同名规则模式,覆盖未来 .env.local /
    .env.production 等变体,保证 git 与 docker 两通道一致

详见 VULNERABILITY_REPORT.md
"
2026-08-25 23:09:47 +08:00
sun 78c72004bf chore: 删除 ratelimit_middleware.go.tmp 临时文件
与 ratelimit_middleware.go 内容完全重复(SHA256 一致,1116 字节),
且无任何代码引用 .tmp 路径,属于误提交的开发期残留文件。
2026-08-23 13:15:15 +08:00
22 changed files with 803 additions and 86 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
*.exe
*.md
.env
.env.example
.env.*
!.env.example
.git
.gitignore
tts_api_architecture.html
+11
View File
@@ -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
+65
View File
@@ -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 }}
+10 -2
View File
@@ -1,4 +1,4 @@
# Go build cache
# Go build cache
.gocache/
*.exe
*.test
@@ -11,4 +11,12 @@
Thumbs.db
# Logs
*.log
*.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
+9 -1
View File
@@ -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
+144 -3
View File
@@ -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`
+197
View File
@@ -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`。
**影响**: 客户端(浏览器 `<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` 之外的明显并发竞态:全局配置启动期写入后只读,共享状态均有锁
+12 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"log"
"strings"
"time"
"github.com/volcano-tts/tts-api/common"
@@ -94,10 +95,13 @@ func Synthesis(
if resp.StatusCode != 200 {
rawBody := ReadErrorBody(resp.Body)
// rawBody 来自上游响应体,可能是攻击者控制的恶意内容(例如包含
// \n 伪造日志行)。转义后再嵌入错误消息。
safeBody := strings.NewReplacer("\n", "\\n", "\r", "\\r").Replace(rawBody)
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, fmt.Sprintf("http_%d", resp.StatusCode), time.Since(started), 0, 0, 0, resp.StatusCode)
return nil, &UpstreamError{
Code: resp.StatusCode,
Message: fmt.Sprintf("upstream http %d: %s", resp.StatusCode, rawBody),
Message: fmt.Sprintf("upstream http %d: %s", resp.StatusCode, safeBody),
Stage: "http",
}
}
@@ -116,7 +120,14 @@ func Synthesis(
duration := time.Since(started)
finalData := parsed.AudioData
// finalFormat 反映真实输出格式(用于 controller 写 Content-Type):
// - wav 走 pcm 上游 + 本地拼头,对外仍是 wav
// - aac/flac 在上方已被上游降级为 mp3,真实输出也是 mp3
// - 其余与 clientFormat 一致
finalFormat := clientFormat
if clientFormat != "wav" {
finalFormat = opts.Format
}
sampleRate := opts.SampleRate
if clientFormat == "wav" {
wav, wrapErr := WrapWAVHeader(parsed.AudioData, opts.SampleRate)
-2
View File
@@ -15,8 +15,6 @@ const (
MaxRequestBodySize = 1024 * 1024
RateLimitRequests = 100
RateLimitWindow = time.Minute
MaxResponseTimes = 100
MaxErrors = 10
MaxConcurrentRequests = 10
CleanupInterval = time.Hour
MaxModelNameLength = 64
+3 -4
View File
@@ -18,6 +18,7 @@ import (
"github.com/volcano-tts/tts-api/middleware"
"github.com/volcano-tts/tts-api/setting"
"github.com/volcano-tts/tts-api/telemetry"
"github.com/volcano-tts/tts-api/version"
)
var (
@@ -46,9 +47,6 @@ func resolveClientFormat(reqFmt string) string {
}
return strings.ToLower(reqFmt)
}
if reqFmt == "" {
return setting.TTSOptions.Format
}
return setting.TTSOptions.Format
}
@@ -232,7 +230,8 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
resp := dto.HealthResponse{
Status: status,
Service: "ByteDance TTS to OpenAI API Adapter",
Version: "2.0.0 (v3 API)",
Version: version.Version,
Commit: version.Commit,
Uptime: fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
StartTime: startTime.Format(time.RFC3339),
Memory: collectMemorySnapshot(),
+1
View File
@@ -7,6 +7,7 @@ type HealthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Commit string `json:"commit"`
Uptime string `json:"uptime"`
StartTime string `json:"start_time"`
Memory map[string]interface{} `json:"memory"`
-9
View File
@@ -58,15 +58,6 @@ type V3Usage struct {
TextWords int `json:"text_words"`
}
// ByteDanceTTSConfig 是 setting 包的全局 TTS 配置,目前只承载鉴权 / URL / 超时;
// 完整的合成参数见 adapter/volcano.Options。
type ByteDanceTTSConfig struct {
ApiKey string
ResourceId string
URL string
Timeout time.Duration
}
// SynthesisResult 是火山适配器向 controller 返回的最终结果。
// Format 与 AudioData 的实际编码一致;controller 据此设置响应 Content-Type。
type SynthesisResult struct {
+4 -1
View File
@@ -131,7 +131,10 @@ func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, d
if audioBytes > 0 {
UpstreamBytes.Add(float64(audioBytes), telemetry.Labels{"format": format})
}
if errCode != 0 {
// 上游调用只要 status != "ok" 即视为错误。原版 if errCode != 0 会漏掉
// errCode=0 的 request_error / transport_error / wrap_error / stream_error
// (code=0 的流错误) 等场景,导致 transport 类错误在 /metrics 上完全不可见。
if status != "ok" {
UpstreamErrors.Inc(telemetry.Labels{"code": codeLabel(errCode)})
}
}
-7
View File
@@ -9,13 +9,6 @@ import (
"github.com/volcano-tts/tts-api/setting"
)
// InitAPIKeys 已在 setting.InitAuthConfig 中完成,这里保留为 no-op 以维持现有调用顺序。
// 实际鉴权逻辑直接读 setting.Auth.APIKeys。
func InitAPIKeys() {
// 配置由 setting 包统一加载,日志也由 setting.LogStartupSummary 输出。
_ = setting.Auth
}
func ValidateAPIKey(r *http.Request) bool {
if len(setting.Auth.APIKeys) == 0 {
return true
-7
View File
@@ -13,13 +13,6 @@ var (
corsMaxAgeHeader = "86400"
)
// InitCORSConfig 已在 setting.InitCORSConfig 中完成,这里保留为 no-op 以维持现有调用顺序。
// 实际 CORS 匹配逻辑直接读 setting.CORS.Origins / setting.CORS.AllowAll。
func InitCORSConfig() {
// 配置由 setting 包统一加载,日志也由 setting.LogStartupSummary 输出。
_ = setting.CORS
}
func isValidOrigin(origin string) bool {
if origin == "" || origin == "null" || origin == "nil" {
return false
+5 -1
View File
@@ -3,6 +3,7 @@ package middleware
import (
"log"
"net/http"
"strings"
"time"
)
@@ -23,6 +24,9 @@ func Logger(next http.Handler) http.Handler {
next.ServeHTTP(rec, r)
duration := time.Since(start)
log.Printf("%s %s %s %d %v", r.Method, r.RequestURI, r.RemoteAddr, rec.statusCode, duration)
// r.RequestURI 是未经解析的原始请求行,攻击者可在 URL 中注入
// \n / \r 伪造日志行。转义为可见字符后再记录。
uri := strings.NewReplacer("\n", "\\n", "\r", "\\r").Replace(r.RequestURI)
log.Printf("%s %s %s %d %v", r.Method, uri, r.RemoteAddr, rec.statusCode, duration)
})
}
+75 -3
View File
@@ -4,12 +4,15 @@ import (
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/metrics"
"github.com/volcano-tts/tts-api/setting"
)
type RateLimiter struct {
@@ -23,15 +26,57 @@ type RateLimiter struct {
var (
GlobalRateLimiter *RateLimiter
ConcurrencySem chan struct{}
// trustedProxyHops controls how X-Forwarded-For (XFF) is parsed when the
// direct connection comes from a private IP (i.e., we're behind a reverse
// proxy). Two modes are supported, switched by this single value:
//
// HEURISTIC MODE (trustedProxyHops == 0, the default):
// Walk XFF from the end, return the first PUBLIC IP. Skips private
// and loopback hops automatically. Works for ~90% of deployments
// without the operator needing to know the exact number of proxy
// hops. Trade-off in multi-hop: rate limiting is per-CDN-edge rather
// than per-real-client, which is "good enough" for abuse protection
// but not for fine-grained per-user quotas.
//
// PRECISE MODE (trustedProxyHops > 0):
// Count back N hops from the end of XFF and return that value. Gives
// precise per-real-client rate limiting even in multi-hop setups
// (e.g., Cloudflare + nginx). Operator MUST set this to the number
// of trusted reverse proxies between this service and the client.
//
// Both modes walk from the END of the XFF chain. The first value is
// client-controllable; trusting it would let attackers bypass IP rate
// limiting by sending a forged X-Forwarded-For header.
trustedProxyHops = 0
)
func InitRateLimiter() {
switch v := os.Getenv("TRUSTED_PROXY_HOPS"); {
case v == "":
log.Printf("TRUSTED_PROXY_HOPS 未设置,使用默认启发式模式(XFF 链尾第一个公网 IP)")
default:
n, err := strconv.Atoi(v)
switch {
case err != nil || n < 0 || n > 10:
log.Printf("警告: TRUSTED_PROXY_HOPS=%q 无效(需 0-10 的整数),回退到默认启发式模式", v)
case n == 0:
// "0" 或 "00" 等被 Atoi 解析为 0 的形式都归到启发式模式,
// 避免日志出现"精确模式, 信任 0 跳"这种自相矛盾的输出。
log.Printf("已配置 TRUSTED_PROXY_HOPS=%d(启发式模式,等同默认)", n)
default:
trustedProxyHops = n
log.Printf("已配置 TRUSTED_PROXY_HOPS=%d(精确模式,信任 %d 跳反代)", n, n)
}
}
GlobalRateLimiter = &RateLimiter{
requests: make(map[string][]time.Time),
limit: common.RateLimitRequests,
window: common.RateLimitWindow,
}
ConcurrencySem = make(chan struct{}, common.MaxConcurrentRequests)
// 同步到 setting 包,供 LogStartupSummary 展示
setting.TrustedProxyHops = trustedProxyHops
}
func (rl *RateLimiter) Allow(key string) bool {
@@ -133,10 +178,37 @@ func GetClientIP(r *http.Request) string {
}
if isPrivateIP(directIP) {
// Parse X-Forwarded-For when there's a reverse proxy in front (direct
// connection is from a private IP). Both modes walk from the END of
// the chain so that the client-controllable first value cannot be
// used to spoof a different client IP for rate limit bypass.
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
ip := strings.TrimSpace(strings.Split(xff, ",")[0])
if net.ParseIP(ip) != nil {
return ip
parts := strings.Split(xff, ",")
if trustedProxyHops > 0 {
// PRECISE MODE: count back N hops from end. Real client IP
// sits at index (len(parts) - N). Walk backwards to skip
// any malformed values; if chain is shorter than expected,
// fall through to the first valid IP in the chain.
target := len(parts) - trustedProxyHops
if target < 0 {
target = 0
}
for i := target; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if net.ParseIP(ip) != nil {
return ip
}
}
} else {
// HEURISTIC MODE (default): walk from end, return first
// PUBLIC IP. Skips private/loopback hops that come from
// internal proxies between the public-facing proxy and us.
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if parsed := net.ParseIP(ip); parsed != nil && !isPrivateIP(ip) {
return ip
}
}
}
}
if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
+6 -7
View File
@@ -1,10 +1,9 @@
package middleware
// 本文件提供带 metrics 埋点的限流 / 并发中间件版本;
// 由于原 ratelimit_middleware.go 在本仓库的云盘同步下被永久占用,
// 这里用独立实现覆盖路由使用入口,旧实现保留为未引用代码。
//
// 行为与原 ratelimit_middleware.go 完全一致,只是多了 metrics 调用。
// 本文件提供带 metrics 埋点的限流 / 并发中间件版本。
// 相比 router 实际使用的实现,本版本额外做了:
// - 加 metrics 埋点(限流拒绝 / 并发拒绝计数)
// - 仅对 /v1/ 下的业务请求生效,监控路径(/health /metrics /dashboard)不消耗配额
import (
"log"
@@ -14,7 +13,7 @@ import (
"github.com/volcano-tts/tts-api/metrics"
)
// RateLimitWithMetrics 是 middleware.RateLimit 的可埋点版本。
// RateLimitWithMetrics 是限流中间件,带埋点 + 路径过滤。
func RateLimitWithMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 仅对 /v1/ 下的业务请求限流,/health /metrics /dashboard 等监控路径不限流
@@ -32,7 +31,7 @@ func RateLimitWithMetrics(next http.Handler) http.Handler {
})
}
// ConcurrencyLimitWithMetrics 是 middleware.ConcurrencyLimit 的可埋点版本。
// ConcurrencyLimitWithMetrics 是并发控制中间件,带埋点 + 路径过滤。
func ConcurrencyLimitWithMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 仅对 /v1/ 下的业务请求统计并发和加锁,监控路径不占用并发槽位
-32
View File
@@ -1,32 +0,0 @@
package middleware
import (
"log"
"net/http"
)
func RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := GetClientIP(r)
if !GlobalRateLimiter.Allow(clientIP) {
log.Printf("警告: 已超过IP速率限制,拒绝请求 - 客户端IP: %s", clientIP)
SendJSONError(w, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.", "rate_limit_error", "rate_limit_exceeded")
return
}
next.ServeHTTP(w, r)
})
}
func ConcurrencyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case ConcurrencySem <- struct{}{}:
defer func() { <-ConcurrencySem }()
next.ServeHTTP(w, r)
default:
log.Printf("警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: %s", GetClientIP(r))
SendJSONError(w, http.StatusServiceUnavailable, "Server is busy, maximum concurrent requests reached. Please try again later.", "concurrency_limit_error", "max_concurrent_requests")
return
}
})
}
+229
View File
@@ -0,0 +1,229 @@
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
}
+12 -5
View File
@@ -10,7 +10,6 @@ import (
"github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto"
)
// 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。
@@ -46,6 +45,12 @@ type ServerConfig struct {
var Server ServerConfig
// TrustedProxyHops 由 middleware.InitRateLimiter 在启动期写入,
// 表示当前 XFF 解析模式:0=启发式,N>0=精确 N 跳。
// setting.LogStartupSummary 读这个字段以展示运行期配置,
// 不直接调用 middleware(避免循环 import)。
var TrustedProxyHops int
// InitAllConfigs 集中初始化所有配置,启动期调用一次。
func InitAllConfigs() {
InitServerConfig()
@@ -257,6 +262,12 @@ func LogStartupSummary() {
log.Printf("ALLOWED_ORIGINS: 已配置 %d 个允许的跨域来源白名单", len(CORS.Origins))
}
if h := TrustedProxyHops; h == 0 {
log.Printf("TRUSTED_PROXY_HOPS: 启发式模式(默认,XFF 链尾第一个公网 IP)")
} else {
log.Printf("TRUSTED_PROXY_HOPS: 精确模式,信任 %d 跳反代", h)
}
log.Printf("火山 TTS 必填项状态:")
type ttsCheck struct {
name string
@@ -305,7 +316,3 @@ func CheckStaticFiles() {
log.Println("警告: health.html 不存在,/dashboard 路由将返回 404")
}
}
// 保留 dto.ByteDanceTTSConfig 引用避免 import 警告;
// 新代码不应再使用这个类型,设置已在 TTSOptions 中。
var _ = dto.ByteDanceTTSConfig{}
+18
View File
@@ -0,0 +1,18 @@
// Package version 提供构建时注入的版本信息。
//
// Version 和 Commit 在编译时通过 -ldflags 注入:
//
// go build -ldflags "-X github.com/volcano-tts/tts-api/version.Version=$VERSION \
// -X github.com/volcano-tts/tts-api/version.Commit=$COMMIT"
//
// 开发时默认 "dev",CI/CD 时通常由 git describe 自动算出:
// VERSION=$(git describe --tags --always --dirty)
// COMMIT=$(git rev-parse --short HEAD)
//
// /health 端点会暴露这两个值,方便运维确认"跑的到底是哪个 commit"。
package version
var (
Version = "dev"
Commit = "dev"
)