症状
乘法在解析器轨迹里可见:user 解析 1 次、其 friends 解析 500 次、再一层 25 万次、第三层 1.25 亿次——friends-of-friends 字段每层 500 倍扇出,一个请求就要一亿多次解析,同一批数据走 REST 列表端点毫秒返回,因为形状由服务器控制。租户隔离的破绽同样显眼:一条精心构造的查询让整台服务器对所有租户无响应。CPU 被单个请求钉死数分钟、resolver 同字段解析百万次,是这类炸弹的量化指纹。
诊断思路
先读解析器轨迹按层统计执行次数,乘法即暴露;再采样统计入站查询的深度分布——正常客户端 5 层以内,20 层以上的尾部要么是构造炸弹要么是生成客户端的意外递归;最后用手工炸弹测试验证:未设防的服务器会执行它(漏洞坐实),有深度限制的在执行前拒绝。
根因
GraphQL 执行客户端发送的任何结构。没有深度与复杂度限制时,列表字段上的嵌套查询让解析器工作量指数级放大——每层扇出成逐条解析,一个请求的需求就能超过全部正常流量的总和。
修复与验证
验证期设防:depthLimit(7) 拒绝更深文档、成本分析封顶 query cost;逐条扇出用 DataLoader 批量化——每层一条查询而不是每项一条;生产客户端关闭任意文本通道,只允许持久化查询(注册文档、发送哈希);按复杂度加权的令牌级限速。验证时构造炸弹在验证期被拒并给出清晰错误、代表性合法查询通过、负载测试下每请求的解析器执行计数保持有界。深度限制压到合法客户端需要之下会打断 API——先测量真实查询形状再选阈值。
代码示例
Query.user 1 execution
User.friends 500 executions
User.friends 250,000 executions
User.friends 125,000,000 executions <- the bomb
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7), // reject deeper documents
createCostAnalysis({ maximumCost: 1000 }),
],
});
const userLoader = new DataLoader((ids) =>
db.users.findByIds(ids) // one query per level, not per item
);
// persisted queries only: clients register documents, send hashes
if (!isPersistedQuery(request)) {
return reject('ad-hoc queries disabled');
}