KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

08 · 安全与运维:一条不能带 header 的连接怎么管 — keel 龙骨

这一章回答:EventSource 无法自定义请求头时怎么鉴权,以及长连接在租户隔离、灰度、回滚上要注意什么。

这一章回答:EventSource 无法自定义请求头时怎么鉴权,以及长连接在租户隔离、灰度、回滚上要注意什么。

SSE 的安全问题有一个特殊之处:浏览器原生客户端不能带自定义头,这让"Bearer token"那套标准做法失效。其余部分与普通 HTTP 接口一致,但因为连接是长期的,风险敞口也更长。

一、三种鉴权方案

@router.get("/runs/{run_id}/stream")
async def stream(request: Request, run_id: str, user=Depends(current_user_from_cookie)):
    ...

方案 2:一次性 ticket(短时效凭据)

① 前端先调 POST /runs/{id}/stream-ticket(带正常 Authorization)→ 拿到短时效 ticket(如 60s,单次可用)
② 前端用 ticket 建 SSE:GET /runs/{id}/stream?ticket=xxx
③ 服务端校验 ticket(一次性、绑定 run_id 与 user、用完即失效)
# 日志脱敏示例:不记录 ticket 参数
log_format main '$remote_addr "$request_method $uri" $status ...';  # 用 $uri(不含 query)而不是 $request

方案 3:fetch + ReadableStream(能带 header,但代价自负)

前端放弃 EventSource,改用 fetch 手动解析(第 02 章)。

选择建议:

场景 方案
浏览器 + 已有会话 Cookie Cookie
浏览器 + 无 Cookie(跨域/App 内嵌) 一次性 ticket
需要自定义头且能接受自己实现重连 fetch 流
服务端/内部调用 普通 header

不要做:把长期有效的 token 放在 query 参数里。它会进入浏览器历史、Referer、代理日志、CDN 日志,是最常见的凭据泄露途径。

二、授权:连接只是开始,每次推送都要校验

async def stream(run_id, user):
    if not await can_read_run(user, run_id):        # 建立连接时校验
        raise HTTPException(403)
    async for evt in source(run_id):
        if not await can_read_run(user, run_id):    # 长期连接:权限可能被撤销
            yield sse("error", {"code": "forbidden"})
            break
        yield sse(evt.type, mask(evt.payload, user))   # 输出前做字段脱敏

长连接的特殊风险:建立连接时的授权,不代表 30 分钟后仍然有效。权限可能在流进行中被撤销(用户被移出项目、订阅到期)。所以:

三、租户隔离

多租户下 SSE 最容易出的错是串流:

错误 后果 防法
run_id 可枚举且无归属校验 A 用户订阅 B 的运行 每次订阅都校验归属
广播频道按 run_id 而非 tenant:run_id 广播时串到别的租户 key 里带租户前缀
日志/回放缓存不隔离 回放时读到他人数据 缓存 key 带租户维度
key = f"tenant:{tenant_id}:run:{run_id}:stream"    # 而不是 f"run:{run_id}:stream"

四、内容安全:流出去的东西收不回

SSE 一旦发出就无法撤回。所以发出前要处理:

  1. 敏感字段脱敏:在 mask() 里统一处理(密钥、他人数据、内部 ID);
  2. 模型输出过滤:如果流的是模型输出,要有输出侧的安全过滤(与输入侧同样重要);
  3. 截断与配额:限制单条事件的体积(如 64KB)与单流的总量,防止异常内容打爆客户端;
  4. 不要流"完整思维链"给终端用户(第 05 章)。

五、运维:灰度、回滚与容量演练

灰度

长连接功能的灰度比普通接口难,因为连接不会随发布自动切换。要点:

回滚

容量演练

上线前至少做一次:

① 建 N 条连接(目标峰值的 1.5 倍),持续 30 分钟
② 期间观察:fd、内存、nginx 连接数、Redis 连接数、事件速率
③ 主动重启一个实例,观察重连风暴是否被削峰
④ 人为制造慢客户端,验证背压策略生效
⑤ 全部断开后,确认资源在 1 分钟内回落(无泄漏)

第 ⑤ 步最容易被忽略,也最能暴露问题:如果断开后连接数不回落,说明 finally 清理没生效,生产上就是缓慢泄漏直到宕机。


动手:可观察结果

产出 判断标准
一套鉴权方案 选定方案并说明理由;日志中不含长期凭据
长期连接的授权复核 权限被撤销后,正在进行的流在 N 秒内断开
租户隔离验证 用 A 租户的凭据订阅 B 的资源被拒绝;广播 key 带租户前缀
输出脱敏 敏感字段在发出前被过滤(抓包确认)
降级开关验证 关闭 SSE 开关后客户端自动走轮询,功能不中断
容量演练记录 五项演练全部通过,断开后资源 1 分钟内回落

完成标志:这套 SSE 服务能通过一次"安全 + 容量"评审:鉴权无长期凭据暴露、权限可撤销、租户不串流、可降级、可回滚。

故障注入

注入方式 观察
把长期 token 放 query 参数 是否出现在 nginx 访问日志、Referer、浏览器历史
建立连接后撤销用户权限 流是否继续推送(应为否)
用 A 的凭据订阅 B 的 run_id 是否被拒绝
广播 key 不带租户前缀 是否出现跨租户事件串流
流中包含敏感字段不过滤 抓包是否能看到(验证脱敏必要性)
客户端断开后不清理 连接数是否回落(泄漏检测)
强制关闭 SSE 功能 客户端是否自动降级到轮询

自测题

  1. EventSource 不能自定义请求头,三种鉴权方案的适用场景与代价分别是什么?
  2. 为什么长期连接的授权需要在推送过程中复核?
  3. 多租户下最容易出现的串流错误是什么?怎么在 key 设计上杜绝?
  4. 为什么"断开后资源是否回落"是容量演练的必检项?
  5. 长连接功能的灰度与回滚,和普通接口相比多了哪些难点?

进入 keel 阅读