跳到主要内容

TURN凭据与防滥用

TURN 是 WebRTC 链路里最容易被白嫖的组件。它的本质是一个鉴权后的 UDP/TCP 中继代理,一旦凭据失守,攻击者可以用你的服务器中转任意流量:刷币、扫端口、撞库、做匿名出口节点。本文给出工程上经过生产验证的最小可用方案:短期凭据 + 配额 + peer-ip 黑白名单 + 监控告警。

适用读者:负责 WebRTC 信令、媒体后端、SRE、安全合规岗位。默认你已经理解 ICE/STUN/TURN 三件套以及 coturn 的基本部署。

1. 为什么 TURN 凭据必须短期

1.1 长期凭据的真实事故面

  • 被爬虫白嫖:前端硬编码 username/credential 等于把 TURN 公开。GitHub、Wayback Machine、CDN 缓存都会留痕,几小时内会被扫描器收录。
  • 匿名出口代理:TURN 默认转发任意 UDP/TCP,攻击者可以把你的 TURN 当成 SOCKS5 跳板做扫描、撞库、攻击第三方。出口 IP 是你的,背锅的是你。
  • 刷流量打爆账单:云上 TURN 走的是公网带宽,攻击者用 100 Mbps 持续中继 24 小时,单台机器一天就能烧掉数百到数千元。
  • 无法吊销:长期凭据没有过期机制,一旦泄露,唯一止损方式是改 secret 然后让所有真实用户重连——这等于一次故障。

1.2 短期凭据解决了什么

  • TTL 内自爆:5~10 分钟过期,泄露的凭据攻击窗口极短。
  • 无需服务端存储:HMAC 派生,coturn 本地校验,签发服务无状态。
  • 可绑定业务身份username 里嵌入 userId/roomId,后期审计能定位到人。
  • 吊销简单:直接换 static-auth-secret,所有未过期凭据立即失效。

1.3 什么时候不适用

  • 完全私有的内网 TURN(无公网入口、IP 白名单严格):长期凭据风险可控,但仍建议短期,统一运维心智。
  • 设备类场景(IoT 摄像头)凭据更新链路不稳:可以适当拉长 TTL 到 1~6 小时,但不要超过 24 小时,且必须配合 user-quota

1.4 失败时的样子

  • 客户端日志大量 401 Unauthorized438 Stale Nonce:TTL 太短或时钟漂移。
  • coturn 日志出现陌生的 peer ip 段、目标端口非 UDP/TCP 标准媒体口:凭据已被滥用。
  • 流量监控看到 TURN 入向带宽与房间内会话数严重不匹配:被当跳板了。

2. REST API 短期凭据机制(RFC 8489 附录 B)

2.0 端到端时序

2.1 协议定义

RFC 8489 附录 B 描述了一种"无需服务端会话状态"的短期凭据方案,coturn 通过 use-auth-secret 实现:

username = <expiry-unix-timestamp> ":" <identifier>
password = base64( HMAC-SHA1( shared-secret, username ) )
  • expiry-unix-timestamp:凭据失效的 Unix 时间戳(秒)。
  • identifier:业务侧的用户/房间标识,仅用于审计和日志,coturn 不校验内容。
  • shared-secret:业务后端与 coturn 共享的对称密钥,绝不下发到客户端
  • HMAC 算法固定为 SHA-1(这里 SHA-1 仅做 MAC 用途,没有抗碰撞需求,不存在安全问题)。

校验时 coturn 自己用同一份 secret 重新计算 HMAC,比对 password;同时检查 expiry-unix-timestamp > now(),超时直接 401。

2.2 为什么是 HMAC-SHA1

  • 无状态:服务端不需要维护"已签发凭据表",水平扩展简单。
  • 签发开销极低:单核每秒可生成数十万条,签发服务可以做成单机、无依赖。
  • 跨语言一致:HMAC-SHA1 在所有语言里行为完全一致,签发服务用 Node/Go/Python 都能和 C 实现的 coturn 对齐。

2.3 什么时候不适用

  • 需要"一次一密"或"绑定到具体 ICE allocation"的强审计场景:标准 REST 方案给不了,需要走 OAuth 或自研账号体系。
  • 需要按用户做带宽差异化(VIP 用户更高带宽):REST 方案里 quota 是 coturn 全局配置,做不到精细化,需要给不同用户挂不同 realm。

3. coturn 完整配置示例

3.1 turnserver.conf

# /etc/coturn/turnserver.conf
# coturn 4.6+,2026-06-22 核对

# ===== 监听 =====
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0
relay-ip=203.0.113.10 # 公网出向 IP,单网卡可省略
external-ip=203.0.113.10 # NAT 后必填,否则 srflx 候选错误
min-port=49152
max-port=65535

# ===== TLS(turns:)=====
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
cipher-list="ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!MD5:!RC4"
no-tlsv1
no-tlsv1_1

# ===== 认证:REST API 短期凭据 =====
use-auth-secret
static-auth-secret=REPLACE_WITH_64_BYTES_RANDOM_HEX
realm=turn.example.com
# 关闭长期凭据,避免运维误开
no-auth-pings

# ===== 配额(防滥用核心)=====
# 单个 username 同时只能持有 12 个 allocation
user-quota=12
# 全服务最多 1200 个 allocation
total-quota=1200
# 全服务带宽上限,单位 kbps(这里 200 Mbps)
bps-capacity=200000
# 单个会话带宽上限,单位 bps(这里 2 Mbps,720p 视频足够)
max-bps=2000000

# ===== Peer IP 黑名单:防 SSRF =====
# 拒绝中继到任何内网 / 链路本地 / 回环 / 多播 / CGNAT
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.0.0.0-192.0.0.255
denied-peer-ip=192.0.2.0-192.0.2.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=198.18.0.0-198.19.255.255
denied-peer-ip=198.51.100.0-198.51.100.255
denied-peer-ip=203.0.113.0-203.0.113.255
denied-peer-ip=224.0.0.0-239.255.255.255
denied-peer-ip=240.0.0.0-255.255.255.255
# IPv6 关键段
denied-peer-ip=::1
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff

# ===== 关闭无用功能(缩小攻击面)=====
no-cli # 关掉 telnet 控制台
no-multicast-peers # 拒绝多播 peer
no-loopback-peers # 拒绝 127.0.0.0/8 peer(与上面 denied-peer-ip 双保险)
no-rfc5780 # 关掉 NAT 行为发现,避免被探测
no-stun-backward-compatibility
secure-stun # STUN binding 也要鉴权
mobility # 允许 ICE mobility,移动端断网恢复

# ===== 日志 =====
log-file=/var/log/coturn/turnserver.log
syslog
verbose
# 生产建议关掉 verbose,改用 simple-log
# simple-log

# ===== 高可用 =====
fingerprint
lt-cred-mech # use-auth-secret 复用 lt 机制头部,必须开
no-stdout-log
pidfile=/var/run/turnserver.pid
proc-user=turnserver
proc-group=turnserver

关键点:use-auth-secretlt-cred-mech 必须同时开启,coturn 把 REST 短期凭据当作长期凭据机制的一种特例处理。

3.2 配置项语义速查

配置作用失守后果
use-auth-secret启用 REST 短期凭据不开就退化成长期凭据
static-auth-secretHMAC 共享密钥泄露=任意人签凭据
realm凭据所属域,多租户隔离不设无法多租户复用集群
user-quota单 username 最大 allocation 数不设单凭据可无限开会话
total-quota全服务最大 allocation不设容易 OOM
bps-capacity全服务总带宽上限 (kbps)不设可被打爆账单
max-bps单会话带宽上限 (bps)不设单用户可吃满整机
denied-peer-ip拒绝中继到指定网段不设变成 SSRF 跳板
allowed-peer-ip仅允许中继到指定网段内网专用 TURN 必备
no-multicast-peers拒绝多播目的不设可做 UDP 反射攻击

4. 服务端签发凭据:Node.js 实现

4.1 最小实现

// turn-credential.ts
import { createHmac, randomBytes } from 'node:crypto';

interface IceServerConfig {
urls: string[];
username: string;
credential: string;
/** 凭据失效时间,秒级 Unix timestamp */
expiresAt: number;
}

interface IssueOptions {
/** 业务用户 ID,作为审计标识 */
userId: string;
/** 房间 ID,可选,用于审计与限流 */
roomId?: string;
/** TTL 秒数,默认 600(10 分钟) */
ttlSeconds?: number;
}

const TURN_SECRET = process.env.TURN_SHARED_SECRET;
if (!TURN_SECRET || TURN_SECRET.length < 32) {
throw new Error('TURN_SHARED_SECRET 必须配置且长度 >= 32');
}

const TURN_URLS = [
'turn:turn.example.com:3478?transport=udp',
'turn:turn.example.com:3478?transport=tcp',
'turns:turn.example.com:5349?transport=tcp',
];

/**
* 生成 RFC 8489 附录 B 风格的 TURN 短期凭据。
* 与 coturn use-auth-secret + static-auth-secret 配合使用。
*/
export function issueTurnCredential(opts: IssueOptions): IceServerConfig {
const ttl = clampTtl(opts.ttlSeconds ?? 600);
const expiresAt = Math.floor(Date.now() / 1000) + ttl;

// identifier 格式:roomId.userId.随机熵
// 随机熵防止同一用户同一时刻拿到相同 username(便于按 allocation 维度做审计)
const nonce = randomBytes(4).toString('hex');
const identifier = [opts.roomId ?? 'g', opts.userId, nonce]
.map(sanitize)
.join('.');

const username = `${expiresAt}:${identifier}`;
const credential = createHmac('sha1', TURN_SECRET!)
.update(username)
.digest('base64');

return { urls: TURN_URLS, username, credential, expiresAt };
}

/** 限制 TTL 在合理范围,防止误传 0 或 24h+ */
function clampTtl(ttl: number): number {
const MIN = 60; // 至少 1 分钟
const MAX = 30 * 60; // 最多 30 分钟
return Math.min(MAX, Math.max(MIN, ttl));
}

/** username 不能含 ':'(与 timestamp 分隔符冲突)和空白 */
function sanitize(input: string): string {
return input.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32);
}

4.2 Express 路由层

// routes/ice.ts
import type { Request, Response } from 'express';
import { issueTurnCredential } from '../turn-credential';
import { rateLimit } from '../middleware/rate-limit';
import { requireAuth } from '../middleware/auth';

// 鉴权前置:必须登录态 + JWT 校验通过
router.get(
'/api/ice-servers',
requireAuth,
rateLimit({ windowMs: 60_000, max: 20, key: (req) => req.user.id }),
(req: Request, res: Response) => {
const { roomId } = req.query;

// 业务校验:用户必须是房间成员
if (typeof roomId !== 'string' || !roomService.isMember(roomId, req.user.id)) {
return res.status(403).json({ error: 'not_in_room' });
}

const turn = issueTurnCredential({
userId: req.user.id,
roomId,
ttlSeconds: 600,
});

// STUN 走公网公开服务即可,TURN 必须短期
res.set('Cache-Control', 'no-store');
res.json({
iceServers: [
{ urls: ['stun:stun.l.google.com:19302'] },
{
urls: turn.urls,
username: turn.username,
credential: turn.credential,
},
],
expiresAt: turn.expiresAt,
// 客户端在 expiresAt - 60s 时主动刷新
refreshBeforeMs: (turn.expiresAt * 1000) - 60_000,
});
},
);

4.3 客户端续期(防止通话中凭据过期)

// client-side
class TurnCredentialRefresher {
private timer?: number;

async fetch(): Promise<RTCIceServer[]> {
const r = await fetch(`/api/ice-servers?roomId=${encodeURIComponent(roomId)}`, {
credentials: 'include',
});
if (!r.ok) throw new Error(`ice fetch failed: ${r.status}`);
const body = await r.json();
this.scheduleRefresh(body.refreshBeforeMs);
return body.iceServers;
}

private scheduleRefresh(refreshAt: number) {
const wait = Math.max(0, refreshAt - Date.now());
this.timer = window.setTimeout(async () => {
const next = await this.fetch();
// setConfiguration 只影响新 allocation,不会断现有连接
pc.setConfiguration({ iceServers: next });
}, wait);
}
}

重要:RTCPeerConnection.setConfiguration() 不会强制现有 allocation 重新鉴权。已经建立的中继路径会继续工作直到链路本身断开,凭据 TTL 只影响新建 allocation。所以 TTL 5~10 分钟对正常通话毫无影响。

5. 单租户 / 多租户隔离

5.1 单租户(最常见)

只有一个业务系统使用 TURN,配置一个 realm、一个 static-auth-secret 即可。

5.2 多租户

多个业务方共享 coturn 集群,必须做到:

  • realm 隔离:每个租户一个 realm,coturn 支持多 realm 并存(realm 字段写在 username 里)。
  • secret 隔离:每个 realm 用独立 secret,通过 --db + 数据库表做映射,避免单 secret 泄露污染所有租户。
  • 配额隔离:用 user-quota 给每个 realm 设独立上限(coturn 支持 realm-options 自定义)。
  • 允许网段隔离:租户 A 只能中继到 SFU A 集群,用 allowed-peer-ip 限定。

5.3 什么时候不适用多租户

  • 业务规模不大、所有产品线由同一团队维护:单 secret + 单 realm 心智更轻。
  • 强合规场景(医疗、金融):每个租户应独立部署 coturn 实例,避免共享内核被横向探测。

5.4 失败时的样子

  • 租户 A 的用户能拿到租户 B 的 SFU 中继:peer-ip 没隔离。
  • 一个租户被攻击导致 total-quota 打满,其他租户全部新建 allocation 失败:没做 realm 级配额。

6. 防 SSRF:peer-ip 黑名单

6.1 攻击模型

TURN 的本职就是"把客户端的数据中继到任意 peer"。如果不做 peer-ip 限制,攻击者可以:

  1. 用合法凭据建立 allocation。
  2. 发送 CreatePermission / ChannelBind 指向 10.0.0.1:6379169.254.169.254:80(AWS / GCP 元数据接口)。
  3. 通过 Send / ChannelData 发任意 payload,TURN 帮他从 TURN 服务器内网视角访问目标。

这是教科书式 SSRF。AWS 元数据接口 169.254.169.254 不做鉴权,拿到的是机器 IAM 临时凭据,等于 TURN 服务器权限被盗。

6.2 必须屏蔽的网段

网段用途风险
0.0.0.0/8保留任意目的
10.0.0.0/8私有内网横向
100.64.0.0/10CGNAT运营商内网
127.0.0.0/8回环本机服务
169.254.0.0/16链路本地云元数据接口
172.16.0.0/12私有内网横向
192.168.0.0/16私有内网横向
224.0.0.0/4多播反射放大
::1/128fc00::/7fe80::/10IPv6 等价同上

6.3 反向白名单(更安全)

如果你的 SFU 集群 IP 段固定,应该用 allowed-peer-ip 而不是黑名单

# 仅允许中继到自家 SFU 段
allowed-peer-ip=203.0.113.20-203.0.113.40
allowed-peer-ip=2001:db8::/64

白名单的工程优势:默认拒绝、新增网段需要主动配置、不会因为漏写某个保留段被绕过。

6.4 什么时候不适用

  • P2P 场景下 peer 是任意客户端公网 IP:不能上严格白名单,只能上完整黑名单 + 配额 + 监控。
  • 只做内网 TURN(VPN 入口):直接 allowed-peer-ip 锁死内网段即可,黑名单可省。

7. 滥用监控指标与告警

7.1 必看指标

指标数据来源告警阈值(参考)
TURN 入向带宽 / 出向带宽iptables、nftables、ifstat超过历史 P95 的 2 倍
同时 allocation 数coturn --prometheus超过 total-quota 的 80%
单凭据并发 allocationcoturn 日志解析超过 user-quota 的 80%
单凭据累计流量日志聚合单 username 5 分钟内 > 100 MB
Peer IP 多样性日志聚合单凭据 5 分钟内 peer IP 数 > 50
地理异常凭据签发日志 vs allocation 源 IP同凭据签发地 vs 使用地国家不一致
401/438 比例coturn 日志突然上升 → secret 泄露后被遍历,或客户端时钟漂移

7.2 coturn Prometheus 指标

coturn 4.6+ 内置 Prometheus exporter,启用:

prometheus
prometheus-port=9641
prometheus-address=127.0.0.1

关键指标:

  • coturn_allocations_total{realm}:累计 allocation 数。
  • coturn_traffic_bytes_total{realm,direction}:累计流量。
  • coturn_sessions{realm}:当前活跃会话。

接 Grafana 即可。

7.3 业务侧审计日志

签发服务必须落库以下字段:

timestamp | userId | roomId | clientIp | userAgent | username | expiresAt

排查滥用时,用 username 反查到 userId,再看用户的登录历史、设备指纹。如果 username 在 coturn 日志里出现但签发库里没有记录,立即换 secret——这意味着 secret 已经被人独立计算了。

8. 流量配额与计费上限

8.1 三级护栏

单会话上限 (max-bps) → 单用户上限 (user-quota * max-bps) → 全服务上限 (bps-capacity)

计算示例:720p 视频会议,单流约 1.5 Mbps,给点冗余设 max-bps=2000000。一个用户最多 12 个 allocation,理论峰值 24 Mbps;服务端 200 Mbps 容量可承受 8 个并发重度用户或上百个轻度用户。

8.2 云账单兜底

仅靠 coturn 配置不够,云厂商侧也要做:

  • 流量阈值告警:CloudWatch、阿里云监控按小时阈值发报警。
  • 网卡限速:宿主机 tc qdisc 或云厂商网卡 QoS,硬上限不依赖 coturn 进程是否健康。
  • 预算硬熔断:超过日预算自动停机或切换只读 IAM。

8.3 失败时的样子

  • 凌晨突然出现持续 24h 的入向带宽 100 Mbps 跑满,业务侧无对应房间数据:被刷流量了,立即换 secret + 临时降 bps-capacity
  • coturn 进程内存暴涨:total-quota 没设或设得过大,allocation 表撑爆。

9. nginx 反向代理与签发接口速率限制

TURN 协议本身不能走 HTTP 反代(它是 UDP/TCP 上的二进制协议),但签发凭据的 HTTP 接口必须受控

# /etc/nginx/conf.d/ice-servers.conf

# 按用户身份限速:每分钟 20 次(凭据 10 分钟有效,正常用户没必要更频繁)
limit_req_zone $http_authorization zone=ice_per_user:10m rate=20r/m;
# 按 IP 兜底:未登录尝试每分钟 10 次
limit_req_zone $binary_remote_addr zone=ice_per_ip:10m rate=10r/m;

server {
listen 443 ssl http2;
server_name api.example.com;

ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

# 凭据签发接口
location = /api/ice-servers {
limit_req zone=ice_per_user burst=10 nodelay;
limit_req zone=ice_per_ip burst=5 nodelay;
limit_req_status 429;

# 强制不缓存
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host $host;
proxy_pass http://signaling_upstream;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-store" always;
}

# 防爬:未带 Authorization 直接 401
if ($http_authorization = "") {
return 401;
}
}

要点:

  • 限速维度优先 Authorization(用户级),其次 IP,避免 NAT 后大量真实用户互相影响。
  • 必须 Cache-Control: no-store,否则 CDN 缓存会把同一份凭据下发给多个用户。
  • 接口路径不要太"显眼"(避免用 /turn/credentials),降低被自动化扫描的概率。

10. 日志与告警实操

10.1 关键日志模式

日志可疑信号处置
401 突增secret 泄露被遍历 / 时钟漂移换 secret,检查 NTP
403 Forbidden Peer IP有人尝试 SSRF留证、封 IP、查 username 反查用户
Total quota reached接近 OOM扩容 + 临时降 total-quota
Allocation refresh 高频客户端实现错或攻击看单 username 频率
单 username 出现在 > 5 个源 IP凭据被分发滥用立即吊销该用户、换 secret

10.2 自动化告警示例(PromQL)

# 5 分钟内单 realm 流量突增 3 倍
(
rate(coturn_traffic_bytes_total[5m])
/
rate(coturn_traffic_bytes_total[1h] offset 1h)
) > 3

# allocation 数接近上限
coturn_allocations_total / 1200 > 0.8

# 401 比例异常
rate(coturn_auth_failures_total[5m])
/ ignoring(reason) rate(coturn_auth_attempts_total[5m]) > 0.1

10.3 应急 runbook

发现疑似滥用:

  1. 立即在 nginx 层限制凭据签发接口(rate=1r/m)。
  2. 在 coturn 配置里把 bps-capacity 临时降到日常的 30%。
  3. 旋转 static-auth-secret,reload coturn(已有 allocation 不受影响,新建会被拒)。
  4. 业务侧同步推送客户端"凭据失效,请重新加入"的兜底信令。
  5. 复盘签发日志:找出第一个异常 username,定位泄露点(前端、日志、CDN 缓存)。

11. 反模式

反模式后果正确做法
static-auth-secret 写进客户端任何人都能签凭据,TURN 完全失守secret 只在后端,客户端只拿签好的 username/credential
用永久 username/password一次泄露永久滥用REST 短期凭据,TTL 5~10 分钟
不设 denied-peer-ip / allowed-peer-ipTURN 变 SSRF 跳板,云元数据被盗默认全黑名单 + 业务 SFU 白名单
不设 user-quota单凭据可开无限 allocation 打爆服务器user-quota=12 起步,按业务调
不设 bps-capacity / max-bps一次刷流量打爆账单配置三级带宽护栏 + 云侧硬限速
签发接口无鉴权爬虫即可批量取凭据必须登录态 + 限速 + 房间成员校验
Cache-Control 没设 no-storeCDN 把同一份凭据下发给多个用户显式 no-store
TTL 设置太长(>1 小时)泄露窗口扩大、吊销不及时5~10 分钟,客户端自动续期
凭据日志只记 username 不记 userId出事无法溯源username 内嵌 userId + 业务侧落库审计
把 STUN 和 TURN 混用同一域名同一端口、同一凭据策略STUN 不需要鉴权,混用让攻击面更乱STUN 用公开服务或独立配置,TURN 单独走鉴权
多租户共用单 realm 单 secret一个租户被打,所有租户连坐realm 隔离 + secret 隔离 + 配额隔离
coturn 默认配置直接上线默认开启了一堆调试端口、CLI 口no-cli、关 telnet、关 RFC5780

12. 上线前自查清单

  • static-auth-secret 长度 ≥ 64,仅在后端环境变量
  • 客户端通过 HTTPS 鉴权接口拿凭据,TTL 10 分钟以内
  • coturn 启用 use-auth-secret + lt-cred-mech
  • denied-peer-ip 覆盖所有内网段、169.254.0.0/16、IPv6 等价段
  • 业务 SFU 段已在 allowed-peer-ip(如适用)
  • user-quotatotal-quotabps-capacitymax-bps 全部设值
  • coturn --prometheus 接入监控,配置 5 类核心告警
  • 签发接口走 nginx 限速,user 级 + IP 级双维度
  • 签发日志落库:userId / roomId / username / clientIp / expiresAt
  • 应急 runbook 写好,secret 旋转剧本演练过一次
  • 云账单告警 + 网卡 QoS 硬上限
  • no-clino-loopback-peersno-multicast-peersno-rfc5780 全部启用

13. 权威资料