2019年,GitHub收到了一个看似普通的GraphQL查询。这个查询只有几十行,结构清晰,语法正确。但GitHub的工程师很快发现,执行这个查询会导致服务器资源消耗呈指数级增长——理论上的最大返回数据量超过200亿条记录。这不是编码错误,而是GraphQL设计哲学中固有的安全困境。

一个查询的爆炸性威力

让我们先看一个来自真实案例的查询结构。这是一个针对社交网络GraphQL API的恶意查询:

query {
  searchGroups(name: "", limit: 1000000) {
    users {
      groups {
        users {
          groups {
            users {
              id
            }
          }
        }
      }
    }
  }
}

这个查询从搜索所有群组开始(空字符串匹配一切),然后遍历每个群组的用户、每个用户的群组、每个群组的用户……循环往复。假设系统中有1000个群组,平均每个群组20个成员,每个用户平均属于5个群组,那么这个查询的数据量计算如下:

$$1000 \\times 20 \\times 5 \\times 20 \\times 5 \\times 20 = 200,000,000$$

两亿条记录,从一个HTTP请求中产生。

更令人不安的是,这种攻击不需要高超的技术能力。任何了解GraphQL基础语法的人都可以构造这样的查询。这不是漏洞,而是GraphQL规范允许的合法行为。

GraphQL为何如此脆弱

要理解这个问题,我们需要回到GraphQL的设计初衷。2012年,Facebook内部面临一个具体问题:移动应用需要在一次请求中获取多种类型的数据,而REST API需要多次往返。GraphQL的核心创新在于让客户端决定需要哪些数据,而不是由服务器预设返回格式。

这种"客户端驱动"的范式带来了巨大的灵活性,但也引入了根本性的安全挑战。在REST API中,每个端点的返回数据量是相对固定的——/users/1返回一个用户对象,/users返回一个用户列表。服务器可以很容易地预测和处理负载。

但在GraphQL中,一个查询可以请求任意深度的嵌套数据。这正是图数据库的设计哲学——数据以节点和关系的形式存在,查询就是图遍历。当两个实体类型之间存在循环引用(用户属于群组,群组包含用户),查询深度理论上可以无限延伸。

IBM研究团队在2020年发表的一篇学术论文首次对这个问题进行了形式化分析。他们定义了两种复杂度度量:解析复杂度(resolve complexity)和类型复杂度(type complexity)。前者衡量服务器执行查询所需的解析器调用次数,后者衡量响应数据的大小。

论文的核心发现是:对于具有循环引用的GraphQL模式,最坏情况下的查询复杂度可以是查询规模的指数函数。这不是实现缺陷,而是GraphQL语义的数学必然。

攻击技术的全景图谱

深度嵌套查询

这是最直接的攻击方式。攻击者构造深层嵌套的查询,每一层都增加数据库查询次数。以下是一个针对电商系统的例子:

query {
  product(id: "123") {
    reviews(limit: 100) {
      user {
        orders(limit: 100) {
          products(limit: 100) {
            reviews(limit: 100) {
              content
            }
          }
        }
      }
    }
  }
}

四层嵌套,每层限制100条,理论最大返回量为$100^4 = 100,000,000$条评论记录。即使实际数据量较小,数据库仍需执行大量连接查询来验证数据关系。

循环查询攻击

当GraphQL模式定义了双向关系时(A包含B,B引用A),攻击者可以利用这种循环:

query CircularAttack {
  user(id: "1") {
    friends {
      friends {
        friends {
          id
          name
        }
      }
    }
  }
}

CVE-2023-28867记录了一个真实漏洞:GraphQL Java实现中,精心构造的循环查询可以导致栈溢出。问题的根源在于解析器在处理循环引用时没有适当的深度检测。

批量查询攻击

GraphQL规范允许在单个HTTP请求中发送多个查询。这个特性原本是为了减少网络往返,但攻击者可以利用它绕过传统的请求限流:

[
  {"query": "mutation { login(email: \"[email protected]\", password: \"password1\") { token } }"},
  {"query": "mutation { login(email: \"[email protected]\", password: \"password2\") { token } }"},
  {"query": "mutation { login(email: \"[email protected]\", password: \"password3\") { token } }"}
]

一次HTTP请求,1000次登录尝试。传统的Web应用防火墙(WAF)和速率限制器只会看到"1次请求",完全无法检测内部的攻击行为。

Escape安全团队在2024年披露了一个令人震惊的案例:攻击者利用批量查询绕过了双因素认证。OTP(一次性密码)通常是6位数字,只有100万种可能。攻击者触发OTP生成后,发送一个包含所有可能密码的批量查询:

[
  {"query": "mutation { verifyOTP(code: \"000000\") { success } }"},
  {"query": "mutation { verifyOTP(code: \"000001\") { success } }"}
]

服务器会依次验证每个代码,当找到正确的OTP时返回成功。双因素认证在毫秒间被瓦解。

别名轰炸

GraphQL允许为字段设置别名,这在同一个查询中多次请求同一字段时很有用。攻击者利用这个特性进行别名轰炸:

query AliasBombing {
  user1: user(id: "1") { id name email }
  user2: user(id: "2") { id name email }
  user3: user(id: "3") { id name email }
}

一个HTTP请求,1000次数据库查询。与批量查询不同,别名轰炸是在单个查询对象内部实现的,更容易绕过某些安全检测。

内省信息泄露

GraphQL的内省系统允许客户端查询API的完整模式,包括所有类型、字段和参数。这个功能对于开发非常有用,但在生产环境中可能导致严重的信息泄露:

query IntrospectionQuery {
  __schema {
    types {
      name
      fields {
        name
        type {
          name
        }
      }
    }
  }
}

攻击者可以通过内省查询获取完整的API结构,发现隐藏的管理接口、内部字段和潜在的攻击入口。

防护措施的技术原理

深度限制

这是最基础的防护措施。GraphQL引擎在解析查询时计算最大嵌套深度,拒绝超过阈值的查询:

import depthLimit from 'graphql-depth-limit';

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

深度限制的挑战在于确定合适的阈值。太严格会阻止合法查询,太宽松则无法阻止攻击。一个实用的方法是分析现有查询的实际深度分布,选择一个覆盖95%合法查询的值。

复杂度分析

深度限制无法区分"简单但深层"和"复杂但浅层"的查询。复杂度分析为每个字段分配权重,计算查询的总复杂度:

const schema = new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'Query',
    fields: {
      users: {
        type: new GraphQLList(UserType),
        args: { limit: { type: GraphQLInt } },
        complexity: (args, childComplexity) => 
          args.limit * childComplexity
      }
    }
  })
});

Shopify的GraphQL Admin API实现了精确的成本计算模型:

  • 对象类型:1点
  • 标量和枚举:0点
  • 连接类型(分页):2点 + 返回对象数量
  • 变更操作:10点

每个客户端获得50点/秒的配额,最大累积1000点。一个请求5个对象的连接查询成本为7点,一个变更操作成本为10点。这种基于成本的限流比简单的请求计数更加公平和精确。

批量操作限制

对于批量查询攻击,需要在操作层面进行限流,而非请求层面:

const operationLimits = {
  maxBatchSize: 10,
  maxAliases: 20,
  sensitiveOperations: {
    login: { batchable: false },
    verifyOTP: { batchable: false },
    resetPassword: { batchable: false }
  }
};

GraphQL Armor是一个开源安全中间件,提供了这些开箱即用的保护:

import { ArmorPlugin } from '@escape.tech/graphql-armor';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ArmorPlugin({
      maxDepth: 7,
      maxComplexity: 1000,
      maxBatchSize: 10,
      maxAliases: 20
    })
  ]
});

超时机制

对于复杂度难以预测的查询,执行超时是最后的防线。多层超时策略更加可靠:解析器级别1-2秒,查询执行级别5-10秒,HTTP请求级别30秒。

内省控制

生产环境应禁用内省,或限制访问:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production'
});

生产环境的最佳实践

GitHub在修复指数级查询漏洞时,采用了多层次的防护策略。他们的分析显示,原有限制机制没有正确处理无分页参数的列表字段。修复后的版本引入了默认列表大小限制,并增强了复杂度分析。

Hasura Cloud提供了完整的API限制配置界面,支持按角色设置不同的限制:

api_limits:
  anonymous:
    depth_limit: 3
    rate_limit: 10
    node_limit: 100
  user:
    depth_limit: 7
    rate_limit: 100
    node_limit: 1000

IBM研究团队的论文还提出了一个重要观点:静态分析可以提供查询成本的紧上界,且时间复杂度为$O(n)$,其中$n$是查询字符串的长度。这意味着复杂度检查可以高效地集成到API网关中,无需与后端服务交互。

权衡与局限

没有完美的安全解决方案,GraphQL防护也不例外。

深度限制的局限:一个查询深度为2的请求可能比深度为10的简单查询消耗更多资源。

复杂度分析的挑战:精确的复杂度计算需要了解数据分布。在不知道实际有多少用户的情况下,很难评估users(limit: 100)的真实成本。

批量限制的影响:禁止批量查询会增加网络往返,影响性能敏感的应用。需要为合法用例提供替代方案。

超时的盲区:超时只能停止已经执行的查询,无法挽回已经消耗的资源。

总结

GraphQL的安全问题不是一个需要"修复"的bug,而是一个需要在设计阶段就纳入考虑的架构决策。一个查询让数据库负载暴增一万倍,这不是技术故障,而是GraphQL灵活性的数学必然。

理解这些攻击原理,不是为了鼓励攻击行为,而是为了构建更健壮的防御体系。深度限制、复杂度分析、批量限制、超时机制——这些防护措施不是互斥的,而是需要协同工作的防线。

当你在设计下一个GraphQL API时,请记住:客户端可以请求任何数据组合,但服务器必须决定接受哪些请求。这个决定,不应该在请求到达之后才做。