☰
GraphQL订阅安全指南:从鉴权缺失到越权推送的攻防实录
2026/10/15 16:11:00 网站建设 项目流程

1. 从一次“翻车”说起:订阅端点到底多容易被忽略

去年我在帮某团队做一次 GraphQL API 安全审计,当时他们的查询(Query)和变更(Mutation)防护已经做得相当到位:有完整的鉴权中间件、有深度限制(Depth Limit)、有复杂度分析(Complexity Analysis),甚至对每个字段的返回都做了脱敏。我当时习惯性地用 GraphQL 客户端打开他们的 playground,尝试拉取 schema,发现查询端点的 introspection 已经关闭,变更端点也需要动态令牌。说实话,这个团队的意识已经超过不少公司了。

但就在我随手点开一条 Subscription 的时候,问题来了。

那是一个实时订单通知的订阅,大概是“订阅当前用户的最新订单状态”。客户端往 WebSocket 连接发送了一个订阅请求,里面带着用户ID,而服务端居然只是从订阅参数里取了用户ID,然后就能成功订阅。我把它改成另一个人的用户ID,照样能收到那个人订单变更的实时推送。更讽刺的是,这个团队付出了很多努力在查询和变更上,却唯独忘了在 Subscription 这条链路上实施同样的数据归属校验。而这种情况我后来在好几家公司的项目中反复遇见。

GraphQL 订阅(Subscription)的本质是服务端主动向客户端推送数据,底层通常是 WebSocket 或 SSE(Server-Sent Events)。它和 Query 最大的区别在于:Query 是“客户端拉取一次,服务端响应一次”,而 Subscription 是“客户端发起订阅,服务端在事件发生时持续推送”。这个持续连接的特性,直接让攻击面从“短时请求”变成了“长时通道”,同时引入了一整套和 HTTP 请求完全不同的安全考量。很多人对 GraphQL 的攻击面分析还停留在 Query 和 Mutation 上,订阅往往被当成“一个不太重要的实时功能”一带而过。可事实上,订阅端点一旦出现数据归属缺失、鉴权遗漏、消息注入或事件源滥用,后果往往比普通查询泄露更隐蔽、更严重——因为数据是主动推送到你眼前的,甚至不需要逐个字段去猜。

这篇内容我想从实际踩坑和审计经验出发,把 GraphQL 订阅的安全风险拆开揉碎,讲清楚攻击面是怎么被拓展的、常见的漏洞形态有哪些、以及我们该怎么系统地给订阅端点做“体检”和加固。

2. 订阅机制里藏着哪些攻击面:不只是多了一个 WebSocket

2.1 订阅的“状态化连接”彻底改变了信任模型

要理解订阅攻击面为什么独特,得先看它的通信模型。普通的 HTTP 请求是“无状态”的:客户端发一个请求,服务端鉴权、处理、返回,然后连接基本就结束了。即便有 cookie、token,每一次请求都可以独立校验。但订阅不同,它通常要先建立一个 WebSocket 连接,然后在这个长连接里发送订阅请求,后续的服务端推送全部发生在同一条连接上。这里就出现了几个新的维度:

  • 连接鉴权和业务鉴权之间的空隙:WebSocket 升级时可能鉴权了,但客户端随后发起的订阅请求本身可能没有单独鉴权。很多实现只在建立连接时验证一次 token,之后就默认“这条连接上所有的订阅都是可信的”。
  • 多路复用带来的越权风险:一条连接上可以同时存在多个订阅,每个订阅都有自己独立的参数。如果服务端没有为每个订阅单独做数据归属校验,那么只要连接有效,攻击者就能发起任意参数的订阅。
  • 推送消息的传输链路更长:从事件源(比如数据库触发器、消息队列)到 GraphQL 执行器,再到 WebSocket 发送器,每个环节都可能存在权限校验缺失。订阅的参数校验只是第一道关,事件发生后推送的数据是否还需要重新校验字段级权限,很多人没有意识到这是一个独立的问题。

我之前见过一个架构不太合理的项目:它们的订阅处理器直接从数据库变更日志中取出完整的行数据,然后原封不动塞进推送消息。表面上订阅参数校验了当前用户,可一旦事件源里包含了关联的敏感字段(比如订单行里有价格成本、内部备注),这些字段也会随着推送泄露出去。

2.2 攻击面拓展的两个方向:横向与纵向

订阅带来的攻击面拓展,可以从两个方向理解:

横向拓展指的是暴露面变大了。以前只需要保护 HTTP 路由上的 GraphQL 端点,现在还需要保护 WebSocket 端点、SSE 端点、可能还涉及消息代理(如 Redis Pub/Sub、消息队列)的访问权限。这些组件的配置疏漏(比如认证方式不一致、token 过期策略不同)都可能成为新的突破口。

纵向拓展指的是单个连接的生命周期内,攻击者有更充裕的时间去探测和利用。普通的 Query 攻击往往需要在短时间内发送大量请求来枚举和猜解;订阅则不同,攻击者只要建立连接、发起一个订阅,就可以长期挂着,被动等待服务端推送数据。哪怕每次推送的数据都很小,长时间积累下来的信息量可能远超一次查询能拿到的数据。

还有一个很多人没注意到的点:GraphQL 订阅的 schema 通常隐含着数据模型关系图,攻击者通过观察订阅支持的参数和返回字段,就能推断出后端的业务实体和数据关系,这降低了攻击者构造恶意查询的难度。尤其是 introspection 开着的情况下,攻击者可以轻松列出所有订阅及其参数类型,从中挑选最“肥”的目标——比如订阅“系统库存变化”、订阅“全站用户在线状态”,这类订阅很可能就是数据泄露的入口。

2.3 常见订阅实现方式的差异:WebSocket 与 SSE 的安全差距

现在主流的订阅传输方式有两种:WebSocket 和 SSE。两者的安全模型还真不太一样。

WebSocket 是双向通信,客户端可以向服务端发送多种消息,包括订阅请求、取消订阅、ping/pong 等。这带来了一个好处——可以实现复杂的订阅生命周期管理;也带来一个坏处——消息格式更复杂,攻击面更大。如果服务端没有严格区分“连接控制消息”和“业务数据消息”,攻击者可能借助畸形消息来绕过解析器逻辑。

SSE(Server-Sent Events)是单向的,仅支持服务端向客户端推送,客户端只能通过 HTTP 请求建立连接,不能发送自定义消息。这天然限制了攻击者输入的类型,但 SSE 通常基于普通 HTTP 长连接,如果事件流中包含了敏感数据头或错误信息,泄露途径和 WebSocket 仍有差异。另外,SSE 对连接数限制的处理不当,比 WebSocket 更容易被用于资源耗尽攻击——因为每个 SSE 连接都是一个长期挂起的 HTTP 响应,占用连接池和线程资源。

我在实际审计中,会把这些差异作为风险分析的一部分。如果一个项目只用 SSE,那我会重点关注事件源权限和推送内容过滤;如果用了 WebSocket,那我会额外检查消息处理器对订阅请求的解析、超时机制、以及连接层面的资源隔离。

3. 最常见的订阅安全漏洞:我在真实项目里看到的坑

3.1 缺失数据归属校验:越权订阅的重灾区

这是订阅安全问题里最常见、后果也最直接的一类。

GraphQL 的 Query 一般会怎么写?通常是在 resolver 里从 context 中取出当前用户,比如context.user.id,然后作为查询条件。但到了 Subscription,很多人下意识地把从客户端传来的参数当成了可信输入。我见过大量的订阅 resolver 长这个样子:

subscribe: (parent, args, context) => { // 错误示范:直接从参数取用户ID,没有和 context.user 比对 return pubsub.asyncIterator(`order_${args.userId}`); }

这么写的后果是:只要知道别人的 userID 或者订单 ID,就可以订阅到对方的数据流。更隐蔽的变种是,有些参数不是直接的用户ID,而是“会话ID”“设备ID”“房间ID”这类间接标识。攻击者如果拿到了这些标识(可能是从别的漏洞泄露的,也可能是被枚举出来的),同样可以完成越权订阅。

正确的做法是,订阅参数中如果涉及归属关系,必须和当前连接的身份信息进行交叉校验。要么不信任客户端传的归属标识,只从 context 中解析;要么在subscribe函数里显式地确认args.userId === context.user.id,不匹配就抛异常。

需要注意的是,订阅的校验经常被开发者放在resolve函数而不是subscribe函数里。这是个严重误区。subscribe函数决定“这条事件流是否被推送”,resolve函数决定“推送什么内容”。如果你只在resolve里校验,攻击者虽然不会收到错误事件流的推送,但可能仍然会收到一个null或者异常消息,从而侧信道确认了某个 ID 是存在的。更麻烦的是,如果事件源已经广播了敏感数据,resolve阶段的校验只能决定“推不推送给当前订阅者”,但消息可能已经在消息代理里被消费了,日志里也会有记录。

3.2 连接级别鉴权与订阅级别鉴权的脱节

WebSocket 的鉴权和 HTTP 的鉴权有一个很大的不同:HTTP 可以在每个请求上携带 token,而 WebSocket 的鉴权一般在握手阶段完成,连接建立之后,后续消息默认“该连接已可信”。

这导致了一个被高频利用的问题:连接有效,不代表当前这条订阅请求有效。

我见过一个系统,用户登录后建立 WebSocket 连接,然后在连接上发送这样的订阅消息:

{ "type": "subscribe", "id": "1", "payload": { "query": "subscription { adminAlert { message } }" } }

如果服务端只在握手阶段检查了“用户是否登录”,但没检查“当前用户是否有 adminAlert 权限”,那么任何登录用户都能订阅管理员告警。这种风险在普通的 REST API 或 GraphQL Query 中一般不会出现,因为每次请求都会经过权限中间件;但订阅的长连接设计,让开发者很容易把“身份认证”和“业务授权”混为一谈。

我在审计时,建议用一个简单的 checklist 来对照:

  • 建立 WebSocket 连接时是否验证了 token 的有效性?
  • 连接建立后,新发起一个订阅请求时,是否重新校验业务权限?
  • 用户权限变更(比如被封禁、角色降级)时,是否强制断开现有订阅?
  • 服务端对“同一个连接上多个订阅”的权限是否做到隔离?

任何一个环节缺失,都可能导致权限验证只是“看起来存在”。

3.3 schema 过度暴露:订阅字段本身就是一个情报源

GraphQL 的 introspection 是双刃剑。查询端点上许多团队会关闭 introspection,但订阅端点常常沿用同一个 schema 却忘了这块配置。而订阅字段往往比查询字段更“直白”——它直接描述了系统内部发生什么事件。比如:

type Subscription { inventoryChanged: InventoryPayload userPasswordReset: UserEvent internalErrorReported: ErrorPayload }

如果 introspection 开启,任何人都可以拿到整套内部事件模型。这不是理论风险,而是真实可能被滥用的信息。攻击者可以从订阅类型中分析出系统有哪些内部流程、哪些实体与事件关联,以此定制下一步攻击。

更麻烦的是,订阅类型的 resolver 如果在事件触发时推送异常的堆栈信息或数据库错误片段,这些信息会顺着事件流直接到达客户端。我见过一个案例:某系统的订阅推送在序列化错误对象时没有做字段白名单,直接把内部错误对象的stack和message推送给了前端,导致数据库连接字符串暴露在浏览器控制台的 WebSocket 消息里。

3.4 事件源污染:攻击者能不能“制造”一个事件触发推送?

订阅的风险不仅仅在读取侧。有些系统的订阅事件源来自数据库变更或消息队列,但有些系统的“事件”是客户端通过另一个接口触发的。如果攻击者可以对事件源进行写入,就能控制订阅推送的内容,这通常演变成存储型 XSS 或供应链污染。

举个场景:一个在线协作平台提供了“更新文档标题”的 mutation,服务端把“文档更新”事件发布到消息代理,所有订阅documentUpdated(roomId)的用户都会收到包含新标题的推送。如果服务端没有对标题做 HTML 转义,攻击者把一个恶意脚本作为标题更新上去,所有订阅该房间的客户端都会在通知区域执行这段脚本。这就是典型的通过订阅事件源完成的存储型 XSS 链路。

所以,审查订阅安全时,不能只看订阅端点的解析逻辑,还要看事件从哪里来、内容是否可信、下游是否有输出编码。订阅只是管道,数据安全取决于整条链路的每个环节。

4. 实操:给 GraphQL 订阅端点做一次系统性的安全体检

4.1 梳理现状:先画一张订阅功能的架构图

动手测试之前,我会先要求团队提供一份 GraphQL schema 文件,然后把它导入可视化工具,标记出所有Subscription类型下的字段。接下来,针对每个订阅字段,我需要回答四个问题:

  1. 它监听的事件源是什么?数据库变更、消息队列、内存事件总线,还是外部 webhook?
  2. 订阅建立时,客户端需要提供哪些参数?这些参数和服务端身份之间是否存在关联?
  3. 服务端在推送数据前,有没有对订阅者身份进行校验?校验发生在 subscribe 阶段、resolve 阶段,还是压根没有?
  4. 推送的消息内容包含哪些字段?其中是否有敏感数据?返回字段是否会根据订阅者角色进行裁剪?

这四个问题全部有明确答案之后,才算把订阅功能的基础面搞清楚。很多时候,我让开发者现场回答这些问题,他们答到第三个就开始卡壳——这本身就说明问题。

4.2 静态审计:代码层面的高信号检查点

静态审计的时候,我有几个固定的检查点,命中率非常高:

检查点 A:订阅 resolver 里的身份来源

在 GraphQL 服务端代码中搜索subscribe:关键字,然后检查它的参数来源。如果看见args中带有 id、userId、roomId 之类的参数,并且这些参数直接拼接进 topic 名称或过滤器,那就高度怀疑存在越权订阅。比如:

subscribe: (parent, args) => { return pubsub.asyncIterator(`orders:${args.userId}`); }

这就是典型的“无条件信任客户端参数”。

检查点 B:消息代理的 topic 设计

很多时候,topic 命名本身就是安全设计的一部分。如果 topic 命名是order:{userId}这种模式,并且订阅方法允许客户端任选 userId,那问题就非常明确。合理的做法应该是 topic 中包含“能够通过服务端身份推导出来的信息”,或者至少要在订阅时校验参数与身份的一致性。检查 topic 命名规则,能反过来找出哪些数据可以被用来做枚举。

检查点 C:resolve 函数是否做了字段级过滤

一个订阅的resolve函数通常要负责把事件数据转换成 GraphQL 输出。如果resolve只是直接返回事件 payload 的完整对象,没有做字段选择或裁剪,那么所有订阅者都能拿到事件源的全部字段。这在内部事件总线上可能问题不大,但在对外 B 端产品里就是隐私事故。

检查点 D:WebSocket 连接上下文是否干净

在 subscription 场景中,GraphQL 的 context 是在连接握手时创建的。有些框架支持在连接建立时通过onConnect回调把用户信息注入 context,但如果同一份 context 被所有订阅共享,或者连接建立后用户信息被覆盖成默认值,就会导致订阅鉴权失效。我建议审计一下onConnect的实现,确认 token 解析失败时是否正确终止连接,而不是静默放行。

4.3 动态测试:我如何用脚本验证越权订阅

纸上谈兵不够,我通常会用 GraphQL 客户端或者脚本模拟越权订阅。完整的流程分四步:

第一步:正常身份建立连接

先拿一个合法账号的 token 建立 WebSocket 连接,发送一个订阅请求,确认能正常收到事件推送。这一步是为了验证业务逻辑本身可用。

第二步:篡改订阅参数

在同一条连接上,尝试把订阅参数改成其他人的标识。比如把userId改成另一个账号的 ID,或者把roomId改成任意数字。如果服务端仍然返回订阅成功,并且之后能收到该资源的推送事件,那就是一个确定性的越权漏洞。

第三步:跨角色权限探测

如果第一步和第二步都被拦截了,我还会尝试不同角色之间的权限差异。比如普通用户尝试订阅adminAlert、systemHealth这类管理型事件,看服务端是否会在订阅阶段就拒绝。如果订阅请求成功但一直收不到数据,也要注意:这可能不是安全,而是事件源恰好没有触发而已。为了排除这种干扰,我通常会找一个自己能触发的事件源(比如调用某 mutation)来确认订阅链路是否真的通了。

第四步:模糊测试订阅请求的畸形输入

针对 WebSocket 类型,我会发送一些非法的 JSON、超长的 query、未知的type字段,观察服务端是优雅地返回错误并关闭连接,还是会抛出堆栈信息、挂起连接或错误地“接受”了无效指令。这些畸形输入往往是攻击者发现隐藏逻辑的入口。

这里有一个非常容易踩的坑:动态测试越权订阅时,不要只在客户端层面改参数。因为有的服务端会把订阅的“实际订阅条件”存储在会话里,然后从会话里读取主体身份。你需要同时检查服务端日志,确认服务端实际使用的订阅 topic 是什么。比如你在客户端传入userId = 100,但服务端实际订阅的是context.user.id = 50的 topic,这时候看起来“虽然传了 100 但还是安全的”,实际逻辑是正确的。反之,如果服务端直接使用100作为 topic,那漏洞就是真的。

4.4 一个简化版的订阅安全自测工具思路

如果你不想每次都用脚本人工测试,可以考虑写一个小的自动化检查器,思路也不复杂。用 Python 或者 Node.js 的 GraphQL 客户端库,读取 schema,遍历所有 Subscription 类型的字段。对每个字段,构造一个随机的参数值(比如随机的 UUID),然后用一个低权限账号建立连接并尝试订阅。如果服务端返回订阅成功,则说明该订阅字段可能没有执行严格的权限校验,需要人工复核。这个工具不能替代人工审计,但能很高效地提供一个“可疑清单”。

工具的核心逻辑是“订阅即接受”:因为我们传入的是明显不属于当前用户的随机身份,正常情况下应当被拒绝。如果被接受了,那这条订阅的鉴权就有高概率问题。注意,这个工具只适用于测试环境,千万别直接对着生产环境跑,因为随机参数可能触发大量无效订阅,给后端造成额外的资源压力。

5. 加固订阅端点:从架构设计到代码落地的完整方案

5.1 订阅入口统一鉴权层:把连接生命周期管起来

不管底层是 WebSocket 还是 SSE,我都建议把订阅连接的鉴权统一到一个独立的中间件中,不要在业务 resolver 里散落各自的校验逻辑。这个中间件需要覆盖四个连接生命周期阶段:

  • 建立阶段(connect):验证 token 的有效性,解析用户身份,挂载到连接上下文。如果 token 无效,直接拒绝握手。
  • 订阅阶段(subscribe):校验订阅请求的内容,包括操作权限、数据归属、订阅速率。这一步必须重新从上下文取身份,不能依赖客户端参数。
  • 推送阶段(publish):在真正往连接写数据之前,对每条推送消息做字段级收口。这部分可以放在 GraphQL 的resolve函数或消息总线到 WebSocket 适配器之间的过滤器里。
  • 断开阶段(disconnect):清理上下文、取消订阅、释放资源。

很多框架(比如常见的graphql-subscriptions)提供了onConnect和onDisconnect钩子,但中间的“订阅阶段”需要在subscribe函数里自己实现。我一般会在subscribe函数开头写一个工具函数,比如assertSubscriberAllowed(context, args),统一抛出标准化错误。这样业务代码里不会出现“这里忘了校验”的情况。

5.2 数据归属校验不能只在 subscribe 里:双保险机制

我强烈建议在subscribe和resolve两个阶段都实施校验,但分工不同:

  • subscribe阶段负责“能不能听这条流”,校验粗粒度权限和数据归属;
  • resolve阶段负责“能听到具体什么内容”,校验字段级权限和脱敏规则。

双保险的意义在于,即使事件源返回的数据包含了一些不属于该用户的字段,resolve 阶段也能把它们剥掉。举个实际例子:一个群聊订阅事件源返回的消息对象包含senderId、content、internalVisibility三个字段,前两个可以正常推送,第三个是内部可见性标记。如果只在 subscribe 阶段校验了“用户是否在群里”,那 internalVisibility 就会泄露给所有群员;如果在 resolve 阶段做了字段白名单,只允许输出senderId和content,这个问题就被拦截了。

5.3 事件源安全:对发布端也做访问控制和内容清洗

订阅安全不能只看订阅消费端。事件发布端同样需要安全设计。

首先,发布事件的动作本身要带上必要的上下文信息。比如“订单创建事件”应该包含订单所属用户的 ID,而不是只包含一条订单记录。这样订阅端才有足够信息做归属校验。

其次,发布到消息代理的事件内容应该遵守最小化原则。很多系统把数据库的行对象整个放到消息里,这些对象往往带有内部字段(比如createdBy内部账号、costPrice成本价、deletedAt软删除标记)。合理做法是,在发布端就构造一个“对外事件模型”,只包含允许暴露的字段。这样即使订阅端的校验被绕过,攻击者也拿不到内部敏感字段。

另外,消息代理本身要有访问控制。如果 Redis Pub/Sub 或者消息队列的地址暴露在内网之外,或者没有设置认证,攻击者可能绕过订阅端直接消费事件流。这种问题在云环境里尤其隐蔽——网络安全策略只限制了业务端口,却开放了 Redis 6379 端口。我在审计中多次遇到类似配置。

5.4 资源控制:防止订阅成为拒绝服务攻击的放大器

订阅是长连接,它的资源占用和普通请求完全不同。攻击者不需要发送海量查询,只要建立几百条订阅连接,每条订阅都请求一个高频事件,就可能把服务端和后端事件源压垮。

资源控制要做三层:

连接数限制:每个用户允许建立的最大 WebSocket 连接数需要独立于 HTTP 连接数进行限制。我曾经测试过一个系统,它没有限制连接总数,结果我用一个账号建立了数千条连接,每个连接订阅一个不同的股票行情,后端行情推送通道瞬间被打满,其他正常用户全部收到延迟。

订阅速率限制:客户端的订阅次数也要限流。一条连接上短时间发起大量订阅请求的行为应当被识别为可疑行为,可以直接拒绝或者断开连接。

推送频率控制:如果事件源本身的速率很高(比如物联网传感器数据),需要考虑做节流或合并推送,避免给单个客户端造成过大的数据处理负担。攻击者可能故意订阅所有高频事件,把一个低配服务器拖垮。

在实现这些限制时,我建议把限制值设为可配置,并基于用户等级做差异化处理。例如免费用户可以同时建立 5 条连接,付费用户 20 条。这样既避免影响正常业务,又能控制风险。

5.5 订阅连接的生命周期安全:心跳、超时与强制下线

用户权限变更、账号封禁、token 过期,这些事件发生后,现有订阅连接必须在合理时间内被强制断开。不能等用户自己断开,否则数据推送会一直泄露到会话过期为止。

我建议实现一个“连接撤销机制”:当用户被封禁或权限变更时,通过消息代理广播一个内部指令,所有持有该用户上下文的连接管理器收到指令后主动关闭连接。在单实例场景中,这个机制比较简单,直接遍历进行中的连接即可;在多实例场景下,需要借助 Redis 或数据库维护会话映射。

同时,订阅连接的心跳和超时策略也要注意。WebSocket 的 ping/pong 机制不能只为了保活,还要用来检测异常连接。如果一个连接长时间没有活跃消息,就应该主动释放。否则攻击者可能建立大量“僵尸连接”占用服务端资源,表面上看起来没有流量,实际上资源一直被占用。

6. 常见问题排查与踩坑实录

6.1 明明加了鉴权,为什么越权订阅还是成功了?

我在一个项目里排查过这样的问题:代码评审时明明看到订阅函数里写了权限校验,但动态测试还是能越权。最后定位发现,校验逻辑写在了一个resolve回调里,而subscribe函数只是把事件分发给了所有订阅者。也就是说,攻击者虽然订阅了事件流,不会收到数据(因为 resolve 拒绝了),但服务端仍然接受了订阅请求,并且事件流持续在内部传递。如果事件源本身有副作用(如标记消息已读),那攻击者可能通过订阅来引发服务端的副作用。这种行为在 GraphQL 的语义里很微妙:订阅“成功”但“拿不到数据”,不代表没有风险。

另外,有些框架在订阅参数校验失败后,会返回null,客户端还能继续使用该订阅 ID 发送其他指令。这给了攻击者一个“假设”的信号。正确的做法是校验失败时直接关闭连接或在响应中明确返回错误类型,避免产生歧义。

6.2 推送消息里带上了敏感上下文

这是最隐蔽的泄露方式。订阅功能建立在前端状态管理上,开发者容易把“前端需要的数据”和“后端完整数据”混为一谈。我见过一个订单系统,订阅订单状态变化时推送了完整的订单对象,里面包含下单者的手机号、地址、支付流水号。结果是,只要成功订阅了订单事件(哪怕只是公开的“订单编号”),就能通过推送数据反查下单用户的敏感信息。

遇到这种情况,建议做一个事件模型白名单文件,把每个订阅字段允许输出的字段都列出来,通过代码生成或 lint 规则强制约束。这是我的经验:靠人脑记忆字段安全性,一定会漏。

6.3 连接建立后 token 过期,订阅却还在推送

很多系统的 token 有效期是 2 小时,但订阅连接可以存活几天。服务端往往只在连接建立时校验一次 token,之后无论 token 是否过期、用户是否退出登录,订阅连接都会继续推送数据。这在移动端尤其常见——App 退到后台,WebSocket 自动重连并携带旧 token,服务端没有及时校验 token 的新鲜度。

缓解方案有二:一是在每次推送前检查 session 是否仍然有效;二是结合心跳消息,让客户端在心跳中携带最新 token,服务端比较 token 的有效期。如果发现 token 过期或用户状态异常,立即断开连接。这个逻辑建议放在订阅中间件中,而不是每一条业务 resolver 里,否则容易出现漏判。

6.4 我把 introspection 关闭了,订阅还是泄露了 schema

关闭 introspection 确实是常规操作,但它并不能防止 schema 泄露。因为 GraphQL 的错误信息通常包含字段名和类型名,攻击者通过故意发送不存在的字段,可以从错误提示中反推出合法字段。这个枚举过程在订阅端点上同样有效:通过反复发送不同的订阅 query,观察服务端的响应差异,就能逐步重建出 Subscription 类型结构。

所以,不要把关闭 introspection 当成万能的。更稳妥的做法是:在订阅端点上同时隐藏 schema、限制错误信息的详细程度,并且对订阅请求做速率限制。错误信息需要统一处理,不能在 message 中直接反射后端字段名(例如把Unknown field 'adminAlert' on type 'Subscription'这种信息改成模糊的invalid subscription)。

6.5 动态测试时需要注意的四个坑

最后说说测试阶段的经验。动态测试订阅比测试普通 GraphQL 查询更容易误判,原因有四:

一是事件时机不可控。你订阅了一个低频事件,可能半天都等不到一条推送,这样就算有越权漏洞你也观察不到。建议在测试前,先确认是否有特殊参数可以触发测试事件。或者找一个类似“更新资料”这种可以立即触发的高频功能,绑定到订阅事件上。

二是连接代理干扰。如果测试环境走了代理或负载均衡,WebSocket 可能被负载均衡器缓冲甚至丢弃。建议直接测试后端服务实例,避开接入层。

三是只测了订阅成功,没测推送到达。有时候订阅请求返回成功,但实际推送链路断的(比如消息队列配置错误),你会误以为“服务端接受了越权订阅”就是漏洞,其实漏洞可能根本不存在。这时需要检查服务端日志,确认订阅请求是否真的进入了业务逻辑。

四是权限变更测试的不及时性。如果你先把一个管理员账号的订阅建立好,然后通过管理接口把该账号的权限降级,再观察新的推送是否还在继续。很多实现不会主动断开,这说明权限变更没有联动订阅连接。这类问题最容易在发布生产前的权限演练中被忽略。

7. 最后再分享一点我对订阅安全的理解

做安全审计这么多年,我愈发觉得 GraphQL 订阅的安全问题本质上是“状态管理”和“事件驱动”带来的信任边界模糊。我们习惯了请求-响应的思维模式,潜意识里认为“每次交互都是一次独立的、可校验的会话”,但订阅把这种模式推翻了:一旦连接建立,信任就在连接层持续存在,业务层不再有机会重新评估。

所以,我对于任何准备上订阅功能的团队,都会给出一个朴素但管用的建议:把订阅当成一个独立的资源,而不是 Query 的附属品。给订阅单独设计鉴权策略、单独配置限流、单独记录日志、单独做监控。仅仅做到这一条,就能避免大部分我从真实项目里看到的问题。

在实际操作中,我的体会是,订阅安全没有太多“一招鲜”的技巧,更多是需要一种全局视角:从客户端参数到服务端身份,从事件源到消息代理,从订阅解析到字段裁剪,每一步都要问一句“这里是否验证了该验证的东西”。哪怕一次疏忽,都可能把实时数据推送变成攻击者的直播通道。希望这篇文章能帮你少踩几个我踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询