安全与风控:内容安全、频控与防重放

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 业务幂等,内容侧在五个接入点分别做同步快审与异步深审,权限侧对每一次读写都做服务端会话成员校验;所有阈值写进配置、所有判定写进风险分,风控不可用时的降级策略必须提前定死而不是临场决定。

笔记加载中…