GraphQL 的设计哲学是“按需索取”,但这也意味着攻击者可以通过内省查询摸清整个 API 的骨架。一旦内省功能暴露在公网,数据表结构、字段关系、参数类型甚至隐藏的废弃接口都会一览无余。关闭内省不是简单地改一行配置,而是需要结合架构设计、权限隔离和运行时监控来构建纵深防御。下面直接讲具体的风险原理和落地方案。

内省查询到底能泄露什么

标准的 GraphQL 内省通过 __schema 和 __type 两个元字段递归获取整个服务端类型系统。攻击者发送一段看似无害的查询,就能拿到所有 Query、Mutation、Subscription 的定义,包括每个字段的参数名、参数类型、默认值和描述信息。很多人以为只要不给敏感字段写描述就没事,但字段名本身就包含业务语义,比如 adminDeleteUser、internalTransfer、legacyOrderSync 这些命名直接暴露了管理功能和内部接口。更隐蔽的风险在于枚举值泄露,例如 OrderStatus 枚举里出现 CANCELLED_BY_RISK、FRAUD_REVIEW 这些内部状态,攻击者据此可以推断出风控逻辑。另外,内省还能暴露弃用字段,这些字段往往因为安全缺陷才被标记为 @deprecated,但在彻底移除前仍然是可用的攻击面。

直接关闭内省的三种主流方式

最彻底的方案是在 GraphQL 引擎层直接禁用内省。不同框架的实现路径不同,但核心思路一致:拦截对 __schema 和 __type 的解析请求。

如果你用的是 Apollo Server,可以在构造 ApolloServer 实例时通过 validationRules 引入 NoSchemaIntrospectionCustomRule。代码如下:

import { ApolloServer } from 'apollo-server';
import { NoSchemaIntrospectionCustomRule } from 'graphql';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [NoSchemaIntrospectionCustomRule],
});

这种方式在查询验证阶段就直接拒绝内省请求,返回“GraphQL introspection is not allowed”错误,不会进入执行阶段,性能损耗极低。

对于 express-graphql 或基于 graphql-http 的中间件,可以在自定义的 execute 函数里做判断。核心逻辑是遍历请求文档中的每个操作,检查 selectionSet 里是否包含 __schema 或 __type 字段。示例代码:

import { parse, validate, execute } from 'graphql';

function blockIntrospection(document) {
  for (const definition of document.definitions) {
    if (definition.kind === 'OperationDefinition') {
      const selections = definition.selectionSet.selections;
      for (const selection of selections) {
        if (selection.name && (selection.name.value === '__schema' || selection.name.value === '__type')) {
          throw new Error('Introspection is disabled');
        }
      }
    }
  }
}

这种方式更灵活,你可以选择性地放行某些内省字段,比如只允许查询 __typename 用于客户端缓存,但禁止查询 __schema。

如果使用 GraphQL Yoga 或 Hot Chocolate 这类全栈框架,它们通常提供了开箱即用的配置项。以 Hot Chocolate 为例,在 Startup 里设置 Introspection 为 false 即可:

services.AddGraphQLServer()
    .ModifyOptions(options => options.Introspection = false);

无论哪种方式,都建议在生产环境默认关闭,而在开发和测试环境保持开启以方便联调。可以通过环境变量控制这个开关,避免人为失误。

关闭内省不等于高枕无忧

很多团队以为关掉内省就万事大吉,但攻击者还有多种手段可以绕过。字段名暴力猜解是最常见的方法,因为 GraphQL 的字段命名通常遵循驼峰或下划线规范,攻击者可以用常见单词组合来探测。例如用 user、users、getUser、queryUser 这些变体轮询,根据响应中的“Cannot query field”和“Field does not exist”错误信息差异来判断字段是否存在。更高级的旁路手段是利用错误消息的差异进行侧信道分析,比如查询一个不存在的字段会返回特定错误码,而查询存在但无权访问的字段会返回权限错误,攻击者据此就能构建出字段地图。

自动补全建议也是信息泄露的重灾区。很多 GraphQL IDE 和工具依赖内省来提供智能提示,但即使关闭了内省,如果服务端仍返回详细的错误堆栈或类型提示,攻击者依然能拼凑出 Schema 轮廓。因此,生产环境必须统一错误响应格式,只返回通用的“Internal Server Error”或自定义的错误码,绝不能把类型不匹配、字段缺失这类细节透出。

基于持久化查询的纵深防御

真正安全的 GraphQL 服务应该采用持久化查询方案。客户端不再发送完整的查询字符串,而是发送一个查询 ID,服务端根据 ID 从预先存储的白名单中取出对应的查询文档执行。这样一来,攻击者连构造任意查询的机会都没有,更谈不上内省或猜解字段。

持久化查询的实现通常分两步。构建阶段,客户端将所有用到的 GraphQL 查询提取出来,生成一个哈希映射表,上传到服务端存储。运行时,客户端发送 JSON 包含 operationId 和 variables,服务端校验 operationId 是否在白名单中,命中则执行,否则直接拒绝。Apollo 生态提供了 PersistedQueryLink 和 apollo-server-plugin-persisted-queries 插件来简化这个过程。如果你用的是自建方案,可以把查询文档的 SHA256 哈希作为 ID,存储在 Redis 或内存字典中。

持久化查询还有一个额外的好处:因为查询文档在服务端已知,可以提前做静态分析和成本评估,防止攻击者通过深度嵌套查询或资源密集型字段进行拒绝服务攻击。你可以对每个持久化查询设定复杂度上限,超过阈值就拒绝执行。

字段级别的访问控制

即使关闭了内省并启用了持久化查询,仍然需要在字段级别做权限控制。因为合法的客户端可能被逆向,攻击者可以从客户端代码中提取出 operationId 和查询结构,然后冒充合法用户调用敏感字段。GraphQL 的解析器是分层执行的,你可以在每个字段的 resolve 函数中注入权限检查逻辑。更好的做法是使用自定义指令,比如 @auth(requires: ADMIN),在 Schema 层面声明字段的访问角色,然后在指令的 visitFieldDefinition 方法里织入权限校验代码。

对于特别敏感的数据,比如用户手机号、身份证号,可以进一步做字段脱敏。在 resolve 函数返回数据前,根据请求方的角色决定是返回明文、掩码还是 null。这样即使攻击者猜到了字段名,拿到的也是脱敏后的数据,无法直接利用。

监控与告警:发现探测行为

防御措施部署后,还需要一套监控体系来发现潜在的探测行为。重点关注以下几类异常模式:短时间内出现大量字段不存在错误,说明有人在暴力猜解;单个 IP 或会话频繁发送包含 __schema 的请求,说明有人在尝试内省;查询中包含了大量不常见的字段组合,可能是攻击者在摸索 Schema 边界。你可以在 GraphQL 网关层或中间件里埋点,将这些指标上报到日志系统或 APM 平台,设置阈值告警。

对于持久化查询场景,任何未被识别的 operationId 请求都应该记录并告警,因为这要么是客户端版本不匹配,要么是有人在伪造请求。通过分析这些被拒绝的请求体,可以及时发现攻击者的探测手法并调整防御策略。

开发与运维的配合

安全配置最终要靠工程规范来保障。Schema 设计阶段就应该遵循最小暴露原则,不要为了方便前端而把所有数据库字段都映射到 GraphQL 类型上。内部使用的管理接口应该拆分为独立的 GraphQL 端点,通过网关层做路由隔离,只对办公网或 VPN 开放。CI/CD 流水线里要加入 Schema 变更审计,每次新增字段或类型时自动触发安全评审,检查是否引入了敏感命名或过度暴露的关联关系。

定期做一次盲测也很关键。在关闭内省的生产环境上,用字典工具模拟攻击者进行字段猜解,看能探测出多少有效字段。根据测试结果反向优化错误提示的模糊度和字段命名规范。比如把 adminDeleteUser 改为 internalUserPurge,虽然不能完全杜绝猜测,但能显著提高攻击成本。

最后,保持 GraphQL 引擎和依赖库的版本更新。很多内省绕过漏洞都是通过框架升级修复的,及时打补丁能堵住已知的攻击路径。