KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
08 · 安全与运维:一条不能带 header 的连接怎么管 — keel 龙骨
这一章回答:EventSource 无法自定义请求头时怎么鉴权,以及长连接在租户隔离、灰度、回滚上要注意什么。
这一章回答:EventSource 无法自定义请求头时怎么鉴权,以及长连接在租户隔离、灰度、回滚上要注意什么。
SSE 的安全问题有一个特殊之处:浏览器原生客户端不能带自定义头,这让"Bearer token"那套标准做法失效。其余部分与普通 HTTP 接口一致,但因为连接是长期的,风险敞口也更长。
一、三种鉴权方案
方案 1:Cookie(浏览器场景首选)
@router.get("/runs/{run_id}/stream")
async def stream(request: Request, run_id: str, user=Depends(current_user_from_cookie)):
...
- ✅ 浏览器自动带上,不需要前端做任何事;
- ✅ 可以用
HttpOnly+Secure+SameSite保护; - ⚠️ 需要防 CSRF:SSE 是 GET 请求,但它不修改状态,所以 CSRF 风险主要在"信息泄露"(攻击者诱导受害者打开一个页面,用受害者 Cookie 建立 SSE 连接读取其数据)。
- 缓解:
SameSite=Lax/Strict;对敏感流的来源做Origin校验。
- 缓解:
方案 2:一次性 ticket(短时效凭据)
① 前端先调 POST /runs/{id}/stream-ticket(带正常 Authorization)→ 拿到短时效 ticket(如 60s,单次可用)
② 前端用 ticket 建 SSE:GET /runs/{id}/stream?ticket=xxx
③ 服务端校验 ticket(一次性、绑定 run_id 与 user、用完即失效)
- ✅ 解决了不能带 header 的问题;
- ✅ ticket 有效期极短,泄露影响面小;
- ⚠️ ticket 会出现在 URL 里 → 不要把它写进访问日志(nginx
access_log默认记录 URI),或把 ticket 放在路径外并单独脱敏。
# 日志脱敏示例:不记录 ticket 参数
log_format main '$remote_addr "$request_method $uri" $status ...'; # 用 $uri(不含 query)而不是 $request
方案 3:fetch + ReadableStream(能带 header,但代价自负)
前端放弃 EventSource,改用 fetch 手动解析(第 02 章)。
- ✅ 可以用标准
Authorization头; - ❌ 自动重连、
Last-Event-ID、retry全部要自己实现。
选择建议:
| 场景 | 方案 |
|---|---|
| 浏览器 + 已有会话 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 分钟后仍然有效。权限可能在流进行中被撤销(用户被移出项目、订阅到期)。所以:
- 周期性重新校验(例如每次收到新事件时,或每 N 秒);
- 提供"立即断开"的能力(管理端踢人时,写标记 → 推送循环检测到 → 断开)。
三、租户隔离
多租户下 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 一旦发出就无法撤回。所以发出前要处理:
- 敏感字段脱敏:在
mask()里统一处理(密钥、他人数据、内部 ID); - 模型输出过滤:如果流的是模型输出,要有输出侧的安全过滤(与输入侧同样重要);
- 截断与配额:限制单条事件的体积(如 64KB)与单流的总量,防止异常内容打爆客户端;
- 不要流"完整思维链"给终端用户(第 05 章)。
五、运维:灰度、回滚与容量演练
灰度
长连接功能的灰度比普通接口难,因为连接不会随发布自动切换。要点:
- 新版本通过新路径或新参数启用(如
/v2/stream),让客户端逐步切; - 服务端要能同时支持新旧协议一段时间;
- 观察指标:新路径的 TTFB、重连次数、错误率,与旧路径对比。
回滚
- 客户端侧要有降级开关:SSE 出问题时能退回"轮询 + 全量拉取";
- 这个开关应该是配置驱动的(服务端下发或远程配置),而不是发版才能改;
- 回滚演练:在高峰期强制切断 10% 的 SSE,验证降级路径。
容量演练
上线前至少做一次:
① 建 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 功能 | 客户端是否自动降级到轮询 |
自测题
EventSource不能自定义请求头,三种鉴权方案的适用场景与代价分别是什么?- 为什么长期连接的授权需要在推送过程中复核?
- 多租户下最容易出现的串流错误是什么?怎么在 key 设计上杜绝?
- 为什么"断开后资源是否回落"是容量演练的必检项?
- 长连接功能的灰度与回滚,和普通接口相比多了哪些难点?