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时,请记住:客户端可以请求任何数据组合,但服务器必须决定接受哪些请求。这个决定,不应该在请求到达之后才做。