安全与风控:内容安全、频控与防重放
IM 的安全问题和普通 Web 的差别在于:攻击面是长连接 + 高频写 + 陌生人可达三者的叠加。一个被盗的账号能在几秒内向几千人发广告,一次重放能把一条消息变成一千条。本章按"登录风控 → 上行频控 → 防重放 → 内容安全 → 权限校验"的顺序给出可落地的阈值、字段与接入点。
威胁模型
先列清楚要防什么,否则风控会变成堆功能:
| 风险 | 典型表现 | 主要手段 |
|---|---|---|
| 撞库/盗号 | 同一 IP 短时间尝试大量账号 | 频控 + 验证码 + 设备指纹 + 异地登录校验 |
| 垃圾消息 | 单账号秒级群发广告 | 上行频控 + 内容审核 + 新账号限制 |
| 重放攻击 | 抓包后重复发送同一请求 | 时间戳 + nonce + 签名 |
| 越权访问 | 构造别人的 conv_id 拉历史消息 | 会话成员校验,服务端不信任客户端参数 |
| 内容违规 | 涉政涉黄文本、图片、语音 | 同步快审 + 异步深审 + 回溯扫描 |
| 数据泄露 | 日志打印明文、媒体 URL 无鉴权 | 日志脱敏 + 签名 URL + 字段级加密 |
登录风控:频控与设备指纹
频控要多维度叠加,只在网关上按 IP 限制会被代理池轻松绕过:
| 维度 | 阈值示例 | 超限动作 |
|---|---|---|
| 单 IP 登录尝试 | 20 次/分钟 | 返回 429 + 强制图形验证码 |
| 单账号密码错误 | 5 次/10 分钟 | 锁定 15 分钟,要求短信二次验证 |
| 单设备注册账号 | 10 个/天 | 拒绝并标记设备风险分 |
| 单账号建连 | 30 次/分钟 | 拒绝新连接(防重连风暴与连接耗尽) |
| 单账号发消息 | 20 条/s、600 条/分钟 | 限速排队,持续超限断开连接 |
| 单账号加好友/建群 | 20 次/小时 | 拒绝并转人工审核 |
服务端计数统一放 Redis,用固定窗口 + INCR/EXPIRE 实现,成本最低:
// uid 维度滑动窗口:窗口内计数超阈值即拒绝
func (l *Limiter) Allow(ctx context.Context, uid int64, action string, limit int, win time.Duration) bool {
key := fmt.Sprintf("rl:%s:%d:%d", action, uid, time.Now().Unix()/int64(win.Seconds()))
n, err := l.redis.Incr(ctx, key).Result()
if err != nil {
return true // 限流器故障时放行,但要告警;拒绝全部请求是更大的事故
}
if n == 1 {
l.redis.Expire(ctx, key, win*2) // 留一个窗口的余量,避免边界漏计
}
return n <= limit
}
设备指纹(device fingerprint)是风控的地基:客户端首次启动生成 device_id 并持久化(Keychain / 私有目录),登录、建连、上报 token 时都带上。新设备 + 异地登录组合触发二次验证,比单纯看 IP 有效得多。注意不要采集与隐私政策不符的硬件信息,指纹只用于风控决策,且要能随账号注销一并删除。
消息防重放
长连接场景下,重放的危害不只是重复消息,还有重复扣费、重复触发推送。标准解法是时间戳 + nonce + 签名:
签名串 = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body)
sign = HMAC-SHA256(secret, 签名串) # secret 按设备或会话维度下发并定期轮换
服务端校验三步(任一失败即拒绝,并计入风控计数):
1. |now - timestamp| ≤ 300s # 容忍时钟漂移,窗口不要超过 5 分钟
2. SET replay:{uid}:{nonce} 1 EX 300 NX # 返回 OK 才算首次,否则判定重放
3. 用同样的签名串重算并常量时间比较(hmac.Equal)
三个细节:nonce 必须随请求变化且由客户端随机生成(不要用递增整数,否则重放者可以预判);nonce 的 TTL 只需覆盖时间戳窗口,取 300s 即可,不需要长期保留;比较签名要用常量时间函数,普通字符串比较会泄露时序信息。消息层还可以加一道业务幂等:client_msg_id 唯一索引(见 09 章),即使签名校验被绕过,重复消息也写不进去。
内容安全接入点
审核不是一个接口,而是五个接入点,每个点的失败动作不同:
| 接入点 | 检查内容 | 时延要求 | 失败动作 |
|---|---|---|---|
| 上行消息 | 敏感词(AC 自动机)+ 第三方文本审核 | 同步 < 10ms(敏感词)/ 异步秒级(AI) | 同步命中直接拒绝并返回错误码 |
| 图片/语音/文件 | MD5 秒传前先查黑名单,上传后异步审核 | 异步,秒级到分钟级 | 先发后审:正文先展示占位,命中后撤回 |
| 昵称/签名/群公告 | 机器审核 + 抽样人工 | 秒级 | 拒绝修改或打码展示 |
| 历史消息 | 定期回溯扫描(关键词 + 模型) | 离线 | 批量撤回 + 账号封禁 + 留存证据 |
| 群成员变更 | 新成员入群时补扫其历史发言 | 分钟级 | 风险账号入群需群主确认 |
策略是同步快审兜住体验、异步深审兜住漏网:敏感词用确定性算法保证 P99 在毫秒级,AI 审核异步跑,命中后走"撤回消息 + 通知发送方 + 累加风险分"三件套。审核结果必须按 msg_id 落表,保证可追溯、可申诉。第三方审核服务要做降级设计:服务不可用时策略由产品定死,通常是"放行但标记待检",而不是把消息全拦。
权限校验:会话成员校验
越权是 IM 里最容易出、也最严重的一类漏洞。只要客户端传来 conv_id,就必须服务端校验调用者是否属于该会话:
func (s *MsgService) Send(ctx context.Context, uid, convID int64, body []byte) error {
// 1. 频控(用户维度 + 会话维度双限)
if !s.limiter.Allow(ctx, uid, "msg", 20, time.Second) {
return ErrRateLimited
}
// 2. 成员校验:失败一律拒绝,绝不"降级放行"
ok, err := s.memberCache.IsMember(ctx, convID, uid)
if err != nil {
return err // 校验服务不可用时拒绝写入,读路径可另定策略
}
if !ok {
return ErrNotMember
}
// 3. 状态校验:禁言、拉黑、群已解散、单聊不在好友关系
if err := s.state.Check(ctx, convID, uid); err != nil {
return err
}
// 4. 内容快审(敏感词)
if hit, word := s.sensitive.Match(body); hit {
s.risk.AddScore(ctx, uid, 1, "sensitive:"+word)
return ErrContentRejected
}
return s.persist(ctx, uid, convID, body)
}
成员列表必须缓存(Redis,TTL 60s),并在成员变更时主动失效对应 conv_id 的缓存键,否则退群用户还能发 60 秒消息。同时要注意:拉取历史也要校验权限,退出群聊后通常不允许再拉取退群之后的消息,退群前的可见范围由产品定义,但两端都要有一致的服务端判断——只靠客户端不显示列表,等于没做权限。
常见坑
- 只在网关限流:换设备、换连接即可绕过,频控必须落在 uid 与设备维度上。
- nonce 只记录不清理:内存或 Redis 无限膨胀,务必带 TTL。
- 敏感词库热更新没生效:词库更新要能推送到所有逻辑节点(配置中心或本地文件轮询),并保留新旧版本回滚能力。
- 审核放在客户端:客户端过滤只是体验优化,服务端必须独立校验。
- 日志打印明文:消息正文、token、手机号一律脱敏(正文只记
msg_id与 hash 前 8 位)。 - 风控计数与服务同库:风控 Redis 建议独立实例,避免风控写入把业务缓存打爆。
- 封禁缺少申诉与误伤处理:风控要有分级(提示、限流、禁言、封号),直接封号造成的客诉远超收益。
小结:IM 的安全风控按入口顺序展开——登录用多维度频控加设备指纹与异地校验,上行用 uid 与会话双维度限速,协议层用时间戳加 nonce 加 HMAC 签名防重放并辅以 client_msg_id 业务幂等,内容侧在五个接入点分别做同步快审与异步深审,权限侧对每一次读写都做服务端会话成员校验;所有阈值写进配置、所有判定写进风险分,风控不可用时的降级策略必须提前定死而不是临场决定。